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
This commit is contained in:
parent
200b68e65e
commit
5415d0fe3c
7 changed files with 1438 additions and 23 deletions
499
web_server.py
499
web_server.py
|
|
@ -28652,8 +28652,16 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||||
# Push discover playlists to media server after downloads complete
|
||||
playlist_id = batch.get('playlist_id')
|
||||
if playlist_id and playlist_id.startswith('discover_'):
|
||||
threading.Thread(
|
||||
target=_push_discover_playlist_to_server,
|
||||
args=(batch_id, batch),
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||||
if playlist_id and playlist_id.startswith('youtube_'):
|
||||
url_hash = playlist_id.replace('youtube_', '')
|
||||
if url_hash in youtube_playlist_states:
|
||||
|
|
@ -31361,6 +31369,7 @@ def get_all_downloads_unified():
|
|||
'source_page': batch.get('source_page') or batch.get('initiated_from') or '',
|
||||
'phase': batch.get('phase', 'unknown'),
|
||||
'total': len(queue),
|
||||
'analysis_total': batch.get('analysis_total', len(queue)),
|
||||
'completed': sum(1 for s in statuses if s in ('completed', 'skipped', 'already_owned')),
|
||||
'failed': sum(1 for s in statuses if s in ('failed', 'not_found', 'cancelled')),
|
||||
'active': sum(1 for s in statuses if s in ('downloading', 'searching', 'post_processing')),
|
||||
|
|
@ -31838,8 +31847,27 @@ def _check_batch_completion_v2(batch_id):
|
|||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
elif task_status == 'downloading':
|
||||
task_age = current_time - task.get('status_change_time', current_time)
|
||||
if no_active_workers and task_age > 300: # 5 minutes with no worker running
|
||||
logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in downloading for {task_age:.0f}s with no active workers - forcing failed")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Download stuck for {int(task_age // 60)} minutes with no active worker — timed out'
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
elif task_status in ['completed', 'failed', 'cancelled', 'not_found']:
|
||||
finished_count += 1
|
||||
else:
|
||||
# Catch-all for any other non-terminal state (queued, retrying, etc.)
|
||||
task_age = current_time - task.get('status_change_time', current_time)
|
||||
if no_active_workers and task_age > 600: # 10 minutes with no worker
|
||||
logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in '{task_status}' for {task_age:.0f}s with no active workers - forcing failed")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Task stuck in {task_status} for {int(task_age // 60)} minutes with no active worker — timed out'
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
else:
|
||||
# Task ID in queue but not in download_tasks - treat as completed to prevent blocking
|
||||
logger.warning(f"[Orphaned Task V2] Task {task_id} in queue but not in download_tasks - counting as finished")
|
||||
|
|
@ -31862,6 +31890,9 @@ def _check_batch_completion_v2(batch_id):
|
|||
batch['phase'] = 'complete'
|
||||
batch['completion_time'] = time.time() # Track when batch completed
|
||||
|
||||
# Record sync history completion
|
||||
_record_sync_history_completion(batch_id, batch)
|
||||
|
||||
# Add activity for batch completion
|
||||
playlist_name = batch.get('playlist_name', 'Unknown Playlist')
|
||||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||||
|
|
@ -31880,6 +31911,15 @@ def _check_batch_completion_v2(batch_id):
|
|||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Push discover playlists to media server after downloads complete
|
||||
playlist_id = batch.get('playlist_id')
|
||||
if playlist_id and playlist_id.startswith('discover_'):
|
||||
threading.Thread(
|
||||
target=_push_discover_playlist_to_server,
|
||||
args=(batch_id, batch),
|
||||
daemon=True
|
||||
).start()
|
||||
else:
|
||||
logger.warning(f"[Completion Check V2] Batch {batch_id} already marked complete - skipping duplicate processing")
|
||||
return True # Already complete
|
||||
|
|
@ -32253,7 +32293,7 @@ def _detect_sync_source(playlist_id):
|
|||
('auto_mirror_', 'mirrored'), ('youtube_mirrored_', 'mirrored'),
|
||||
('youtube_', 'youtube'), ('beatport_', 'beatport'),
|
||||
('tidal_', 'tidal'), ('deezer_', 'deezer'), ('listenbrainz_', 'listenbrainz'),
|
||||
('spotify_public_', 'spotify_public'), ('discover_album_', 'discover'),
|
||||
('spotify_public_', 'spotify_public'), ('discover_', 'discover'),
|
||||
('seasonal_album_', 'discover'), ('library_redownload_', 'library'),
|
||||
('issue_download_', 'library'), ('artist_album_', 'spotify'),
|
||||
('enhanced_search_', 'spotify'), ('spotify_library_', 'spotify'),
|
||||
|
|
@ -32354,6 +32394,10 @@ def _record_sync_history_completion(batch_id, batch):
|
|||
completed_count = 0
|
||||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||||
|
||||
logger.warning(f"[SyncHistory] Recording completion for batch {batch_id}: "
|
||||
f"analysis_results={len(analysis_results)}, tracks_found={tracks_found}, "
|
||||
f"queue_len={len(queue)}, failed={failed_count}")
|
||||
|
||||
# Build download status map: track_index → status
|
||||
download_status_map = {}
|
||||
for task_id in queue:
|
||||
|
|
@ -32364,6 +32408,9 @@ def _record_sync_history_completion(batch_id, batch):
|
|||
if task.get('status') == 'completed':
|
||||
completed_count += 1
|
||||
|
||||
logger.warning(f"[SyncHistory] Batch {batch_id}: completed_downloads={completed_count}, "
|
||||
f"download_status_map_size={len(download_status_map)}")
|
||||
|
||||
# Build per-track results from analysis
|
||||
track_results = []
|
||||
for res in analysis_results:
|
||||
|
|
@ -32403,14 +32450,118 @@ def _record_sync_history_completion(batch_id, batch):
|
|||
track_results.append(entry)
|
||||
|
||||
db = MusicDatabase()
|
||||
db.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count)
|
||||
updated = db.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count)
|
||||
logger.warning(f"[SyncHistory] DB update for batch {batch_id}: updated={updated}")
|
||||
|
||||
# Save per-track results
|
||||
if track_results:
|
||||
db.update_sync_history_track_results(batch_id, json.dumps(track_results))
|
||||
tr_updated = db.update_sync_history_track_results(batch_id, json.dumps(track_results))
|
||||
logger.warning(f"[SyncHistory] Track results saved for batch {batch_id}: updated={tr_updated}, count={len(track_results)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record sync history completion: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def _push_discover_playlist_to_server(batch_id, batch):
|
||||
"""After a discover batch completes, push the playlist to the media server.
|
||||
Runs in a background thread. Triggers a scan, waits, then searches for tracks and creates/updates the playlist."""
|
||||
try:
|
||||
playlist_id = batch.get('playlist_id', '')
|
||||
playlist_name = batch.get('playlist_name', '')
|
||||
if not playlist_name:
|
||||
return
|
||||
|
||||
analysis_results = batch.get('analysis_results', [])
|
||||
if not analysis_results:
|
||||
logger.info(f"[DiscoverPush] No analysis results for {playlist_name} - skipping server push")
|
||||
return
|
||||
|
||||
# Build list of tracks that should be in the playlist (found in library OR successfully downloaded)
|
||||
queue = batch.get('queue', [])
|
||||
download_status_map = {}
|
||||
with tasks_lock:
|
||||
for task_id in queue:
|
||||
task = download_tasks.get(task_id, {})
|
||||
ti = task.get('track_index')
|
||||
if ti is not None:
|
||||
download_status_map[ti] = task.get('status', 'unknown')
|
||||
|
||||
tracks_to_find = []
|
||||
for res in analysis_results:
|
||||
idx = res.get('track_index', 0)
|
||||
found = res.get('found', False)
|
||||
dl_status = download_status_map.get(idx)
|
||||
if found or dl_status == 'completed':
|
||||
track_data = res.get('track', {})
|
||||
artists = track_data.get('artists', [])
|
||||
if artists:
|
||||
first = artists[0]
|
||||
artist_name = first.get('name', first) if isinstance(first, dict) else str(first)
|
||||
else:
|
||||
artist_name = ''
|
||||
tracks_to_find.append({
|
||||
'index': idx,
|
||||
'title': track_data.get('name', ''),
|
||||
'artist': artist_name,
|
||||
})
|
||||
|
||||
if not tracks_to_find:
|
||||
logger.info(f"[DiscoverPush] No tracks to push for {playlist_name}")
|
||||
return
|
||||
|
||||
logger.info(f"[DiscoverPush] {playlist_name}: {len(tracks_to_find)} tracks to push to server, triggering scan first")
|
||||
|
||||
# Trigger a library scan so newly downloaded tracks are indexed
|
||||
if navidrome_client and navidrome_client.is_connected():
|
||||
navidrome_client.trigger_library_scan()
|
||||
elif hasattr(web_scan_manager, 'request_scan'):
|
||||
web_scan_manager.request_scan(f"Discover playlist push: {playlist_name}")
|
||||
|
||||
# Wait for scan to finish (poll every 5s, up to 90s)
|
||||
if navidrome_client and navidrome_client.is_connected():
|
||||
for _ in range(18):
|
||||
time.sleep(5)
|
||||
if not navidrome_client.is_library_scanning():
|
||||
break
|
||||
logger.info(f"[DiscoverPush] Scan complete, searching for tracks")
|
||||
else:
|
||||
time.sleep(30)
|
||||
|
||||
# Search for each track on the media server
|
||||
matched_server_tracks = []
|
||||
if navidrome_client and navidrome_client.is_connected():
|
||||
for t in tracks_to_find:
|
||||
results = navidrome_client.search_tracks(t['title'], t['artist'], limit=5)
|
||||
if results:
|
||||
# Use the first result's underlying NavidromeTrack for playlist creation
|
||||
best = results[0]
|
||||
nav_track = getattr(best, '_original_navidrome_track', None)
|
||||
if nav_track:
|
||||
matched_server_tracks.append(nav_track)
|
||||
logger.debug(f"[DiscoverPush] Matched: '{t['title']}' by '{t['artist']}' → {best.id}")
|
||||
else:
|
||||
matched_server_tracks.append(best)
|
||||
else:
|
||||
logger.info(f"[DiscoverPush] No match for: '{t['title']}' by '{t['artist']}'")
|
||||
|
||||
if not matched_server_tracks:
|
||||
logger.warning(f"[DiscoverPush] No tracks matched on server for {playlist_name}")
|
||||
return
|
||||
|
||||
logger.info(f"[DiscoverPush] Pushing {len(matched_server_tracks)}/{len(tracks_to_find)} tracks to '{playlist_name}' on server")
|
||||
success = navidrome_client.update_playlist(playlist_name, matched_server_tracks)
|
||||
if success:
|
||||
logger.info(f"[DiscoverPush] Successfully pushed '{playlist_name}' to server with {len(matched_server_tracks)} tracks")
|
||||
else:
|
||||
logger.warning(f"[DiscoverPush] Failed to push '{playlist_name}' to server")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DiscoverPush] Error pushing playlist to server: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
# ===============================
|
||||
# == SERVER PLAYLIST MANAGER ==
|
||||
|
|
@ -33075,6 +33226,8 @@ def start_missing_tracks_process(playlist_id):
|
|||
_source_page = 'wishlist'
|
||||
elif is_album_download:
|
||||
_source_page = 'album'
|
||||
elif playlist_id.startswith('discover_') or playlist_id.startswith('seasonal_'):
|
||||
_source_page = 'discover'
|
||||
elif playlist_id.startswith('youtube_'):
|
||||
_source_page = 'sync'
|
||||
else:
|
||||
|
|
@ -39217,6 +39370,13 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None,
|
|||
except (IndexError, ValueError):
|
||||
pass
|
||||
else:
|
||||
# Derive source_page from playlist_id prefix
|
||||
if playlist_id.startswith('discover_') or playlist_id.startswith('seasonal_'):
|
||||
_source_page = 'discover'
|
||||
elif playlist_id.startswith('listenbrainz_') or playlist_id.startswith('discover_listenbrainz_'):
|
||||
_source_page = 'discover'
|
||||
else:
|
||||
_source_page = 'sync'
|
||||
_record_sync_history_start(
|
||||
batch_id=sync_batch_id,
|
||||
playlist_id=playlist_id,
|
||||
|
|
@ -39226,7 +39386,7 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None,
|
|||
album_context=None,
|
||||
artist_context=None,
|
||||
playlist_folder_mode=False,
|
||||
source_page='sync'
|
||||
source_page=_source_page
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -43573,6 +43733,9 @@ def refresh_discover_data():
|
|||
|
||||
logger.info(f"[Discover Refresh] Complete! Recent albums: {len(recent_albums)}, Release Radar: {len(release_radar)} tracks, Discovery Weekly: {len(discovery_weekly)} tracks")
|
||||
|
||||
# Auto-sync any "Keep it updated" playlists
|
||||
_auto_sync_discover_playlists(refresh_pid, active_source)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "Discover data refreshed",
|
||||
|
|
@ -43589,6 +43752,269 @@ def refresh_discover_data():
|
|||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
def _auto_sync_discover_playlists(profile_id, active_source):
|
||||
"""Auto-sync Discover playlists that have 'Keep it updated' enabled."""
|
||||
try:
|
||||
playlist_configs = {
|
||||
'release_radar': 'Fresh Tape',
|
||||
'discovery_weekly': 'The Archives',
|
||||
'seasonal_playlist': 'Seasonal Mix',
|
||||
'popular_picks': 'Popular Picks',
|
||||
'hidden_gems': 'Hidden Gems',
|
||||
'discovery_shuffle': 'Discovery Shuffle',
|
||||
'familiar_favorites': 'Familiar Favorites',
|
||||
}
|
||||
|
||||
for ptype, pname in playlist_configs.items():
|
||||
if not config_manager.get(f'discover.auto_sync.{ptype}', False):
|
||||
continue
|
||||
|
||||
logger.info(f"[Auto-Sync] {pname} has 'Keep it updated' enabled, triggering sync...")
|
||||
|
||||
try:
|
||||
database = get_database()
|
||||
tracks = []
|
||||
|
||||
if ptype in ('release_radar', 'discovery_weekly'):
|
||||
curated_ids = database.get_curated_playlist(f'{ptype}_{active_source}', profile_id=profile_id)
|
||||
if not curated_ids:
|
||||
curated_ids = database.get_curated_playlist(ptype, profile_id=profile_id)
|
||||
if curated_ids:
|
||||
pool_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source, profile_id=profile_id)
|
||||
tracks_by_id = {}
|
||||
for track in pool_tracks:
|
||||
tid = None
|
||||
if active_source == 'spotify' and track.spotify_track_id:
|
||||
tid = track.spotify_track_id
|
||||
elif active_source == 'deezer' and getattr(track, 'deezer_track_id', None):
|
||||
tid = track.deezer_track_id
|
||||
elif active_source == 'itunes' and track.itunes_track_id:
|
||||
tid = track.itunes_track_id
|
||||
if tid:
|
||||
tracks_by_id[tid] = track
|
||||
|
||||
for track_id in curated_ids:
|
||||
if track_id in tracks_by_id:
|
||||
t = tracks_by_id[track_id]
|
||||
tracks.append({
|
||||
'id': t.spotify_track_id or getattr(t, 'deezer_track_id', None) or t.itunes_track_id or '',
|
||||
'name': t.track_name,
|
||||
'artists': [t.artist_name],
|
||||
'album': t.album_name,
|
||||
'duration_ms': t.duration_ms or 0
|
||||
})
|
||||
elif ptype == 'seasonal_playlist':
|
||||
from core.seasonal_discovery import SeasonalDiscoveryService
|
||||
seasonal_svc = SeasonalDiscoveryService(database)
|
||||
season_data = seasonal_svc.get_current_season_playlist()
|
||||
if season_data and season_data.get('tracks'):
|
||||
tracks = [{
|
||||
'id': t.get('spotify_track_id', ''),
|
||||
'name': t.get('track_name', ''),
|
||||
'artists': [t.get('artist_name', '')],
|
||||
'album': t.get('album_name', ''),
|
||||
'duration_ms': t.get('duration_ms', 0)
|
||||
} for t in season_data['tracks']]
|
||||
else:
|
||||
from core.personalized_playlists import PersonalizedPlaylistsService
|
||||
service = PersonalizedPlaylistsService(database)
|
||||
method_map = {
|
||||
'popular_picks': service.get_popular_picks,
|
||||
'hidden_gems': service.get_hidden_gems,
|
||||
'discovery_shuffle': service.get_discovery_shuffle,
|
||||
'familiar_favorites': service.get_familiar_favorites,
|
||||
}
|
||||
if ptype in method_map:
|
||||
raw_tracks = method_map[ptype](limit=50)
|
||||
tracks = [{
|
||||
'id': t.get('spotify_track_id', ''),
|
||||
'name': t.get('track_name', ''),
|
||||
'artists': [t.get('artist_name', '')],
|
||||
'album': t.get('album_name', ''),
|
||||
'duration_ms': t.get('duration_ms', 0)
|
||||
} for t in raw_tracks]
|
||||
|
||||
if tracks:
|
||||
virtual_id = f'discover_{ptype}'
|
||||
with sync_lock:
|
||||
if virtual_id in active_sync_workers and not active_sync_workers[virtual_id].done():
|
||||
logger.info(f"[Auto-Sync] {pname} already syncing, skipping")
|
||||
continue
|
||||
sync_states[virtual_id] = {"status": "starting", "progress": {}}
|
||||
future = sync_executor.submit(_run_sync_task, virtual_id, pname, tracks, None, profile_id, '')
|
||||
active_sync_workers[virtual_id] = future
|
||||
logger.info(f"[Auto-Sync] Started sync for {pname} with {len(tracks)} tracks")
|
||||
else:
|
||||
logger.info(f"[Auto-Sync] No tracks available for {pname}, skipping")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Auto-Sync] Error syncing {pname}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Auto-Sync] Error in auto-sync: {e}")
|
||||
|
||||
|
||||
@app.route('/api/discover/synced-playlists', methods=['GET'])
|
||||
def get_discover_synced_playlists():
|
||||
"""Get all Discover playlist types with sync status and auto-update config."""
|
||||
try:
|
||||
database = get_database()
|
||||
active_source = _get_active_discovery_source()
|
||||
pid = get_current_profile_id()
|
||||
|
||||
playlist_types = [
|
||||
{'type': 'release_radar', 'name': 'Fresh Tape', 'description': 'New drops from recent releases', 'icon': '🎵'},
|
||||
{'type': 'discovery_weekly', 'name': 'The Archives', 'description': 'Curated from your collection', 'icon': '📚'},
|
||||
{'type': 'seasonal_playlist', 'name': 'Seasonal Mix', 'description': 'Seasonal curated playlist', 'icon': '🌿'},
|
||||
{'type': 'popular_picks', 'name': 'Popular Picks', 'description': 'Most popular from your discovery pool', 'icon': '🔥'},
|
||||
{'type': 'hidden_gems', 'name': 'Hidden Gems', 'description': 'Underappreciated gems from your pool', 'icon': '💎'},
|
||||
{'type': 'discovery_shuffle', 'name': 'Discovery Shuffle', 'description': 'Random tracks from discovery', 'icon': '🔀'},
|
||||
{'type': 'familiar_favorites', 'name': 'Familiar Favorites', 'description': 'Familiar tracks you love', 'icon': '❤️'},
|
||||
]
|
||||
|
||||
# Check if discovery pool has any data (needed for personalized playlists)
|
||||
try:
|
||||
with database._get_connection() as conn:
|
||||
pool_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM discovery_pool WHERE source = ?", (active_source,)
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
pool_count = 0
|
||||
|
||||
results = []
|
||||
for pt in playlist_types:
|
||||
ptype = pt['type']
|
||||
|
||||
# Get track count
|
||||
track_count = 0
|
||||
if ptype in ('release_radar', 'discovery_weekly'):
|
||||
curated_ids = database.get_curated_playlist(f'{ptype}_{active_source}', profile_id=pid)
|
||||
if not curated_ids:
|
||||
curated_ids = database.get_curated_playlist(ptype, profile_id=pid)
|
||||
track_count = len(curated_ids) if curated_ids else 0
|
||||
elif ptype == 'seasonal_playlist':
|
||||
from core.seasonal_discovery import SeasonalDiscoveryService
|
||||
try:
|
||||
seasonal_svc = SeasonalDiscoveryService(database)
|
||||
season_data = seasonal_svc.get_current_season_playlist()
|
||||
track_count = len(season_data.get('tracks', [])) if season_data else 0
|
||||
except Exception:
|
||||
track_count = 0
|
||||
else:
|
||||
# Personalized playlists come from the discovery pool
|
||||
# familiar_favorites is not implemented — always report 0
|
||||
if ptype == 'familiar_favorites':
|
||||
track_count = 0
|
||||
elif pool_count > 0:
|
||||
track_count = min(50, pool_count)
|
||||
else:
|
||||
track_count = 0
|
||||
|
||||
# Get last sync info
|
||||
virtual_id = f'discover_{ptype}'
|
||||
sync_status = 'never'
|
||||
last_synced = None
|
||||
matched_tracks = 0
|
||||
total_sync_tracks = 0
|
||||
|
||||
with sync_lock:
|
||||
state = sync_states.get(virtual_id)
|
||||
if state and state.get('status') in ('syncing', 'starting'):
|
||||
sync_status = 'syncing'
|
||||
|
||||
# Also check download_batches for active discover batches
|
||||
active_batch_id = None
|
||||
if sync_status == 'never':
|
||||
with tasks_lock:
|
||||
for bid, b in download_batches.items():
|
||||
if b.get('playlist_id') == virtual_id and b.get('phase') not in ('complete', 'error', 'cancelled'):
|
||||
sync_status = 'syncing'
|
||||
active_batch_id = bid
|
||||
break
|
||||
|
||||
if sync_status == 'never':
|
||||
try:
|
||||
entries, _ = database.get_sync_history(source='discover', page=1, limit=100)
|
||||
for entry in entries:
|
||||
if entry.get('playlist_name') == pt['name'] or entry.get('playlist_id', '').startswith(virtual_id):
|
||||
sync_status = 'synced'
|
||||
last_synced = entry.get('completed_at') or entry.get('started_at')
|
||||
matched_tracks = (entry.get('tracks_found') or 0) + (entry.get('tracks_downloaded') or 0)
|
||||
total_sync_tracks = entry.get('total_tracks') or 0
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
auto_update = config_manager.get(f'discover.auto_sync.{ptype}', False)
|
||||
|
||||
# Use actual track count from last sync if available (curated_playlist can be stale)
|
||||
if total_sync_tracks > 0:
|
||||
track_count = total_sync_tracks
|
||||
|
||||
results.append({
|
||||
**pt,
|
||||
'track_count': track_count,
|
||||
'sync_status': sync_status,
|
||||
'last_synced': last_synced,
|
||||
'matched_tracks': matched_tracks,
|
||||
'total_sync_tracks': total_sync_tracks,
|
||||
'auto_update': bool(auto_update),
|
||||
'virtual_id': virtual_id,
|
||||
'active_batch_id': active_batch_id,
|
||||
})
|
||||
|
||||
source_labels = {
|
||||
'spotify': 'Spotify', 'deezer': 'Deezer', 'itunes': 'iTunes/Apple Music',
|
||||
'discogs': 'Discogs', 'hydrabase': 'Hydrabase'
|
||||
}
|
||||
has_any_data = pool_count > 0 or any(r['track_count'] > 0 for r in results)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"playlists": results,
|
||||
"source": active_source,
|
||||
"source_label": source_labels.get(active_source, active_source),
|
||||
"has_data": has_any_data,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting discover synced playlists: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/discover/auto-update', methods=['POST', 'GET'])
|
||||
def manage_discover_auto_update():
|
||||
"""Toggle or get auto-update settings for Discover playlists."""
|
||||
valid_types = ['release_radar', 'discovery_weekly', 'seasonal_playlist',
|
||||
'popular_picks', 'hidden_gems', 'discovery_shuffle', 'familiar_favorites']
|
||||
|
||||
if request.method == 'GET':
|
||||
settings = {}
|
||||
for ptype in valid_types:
|
||||
settings[ptype] = bool(config_manager.get(f'discover.auto_sync.{ptype}', False))
|
||||
# Also include any listenbrainz_* auto-sync settings
|
||||
all_config = config_manager.get('discover.auto_sync', {})
|
||||
if isinstance(all_config, dict):
|
||||
for key, val in all_config.items():
|
||||
if key.startswith('listenbrainz_'):
|
||||
settings[key] = bool(val)
|
||||
return jsonify({"success": True, "settings": settings})
|
||||
|
||||
data = request.get_json()
|
||||
playlist_type = data.get('playlist_type')
|
||||
enabled = data.get('enabled', False)
|
||||
|
||||
is_lb_type = playlist_type and playlist_type.startswith('listenbrainz_')
|
||||
if playlist_type not in valid_types and not is_lb_type:
|
||||
return jsonify({"success": False, "error": f"Invalid playlist type: {playlist_type}"}), 400
|
||||
|
||||
config_manager.set(f'discover.auto_sync.{playlist_type}', bool(enabled))
|
||||
logger.info(f"Discover auto-sync for {playlist_type}: {'enabled' if enabled else 'disabled'}")
|
||||
|
||||
return jsonify({"success": True, "playlist_type": playlist_type, "enabled": bool(enabled)})
|
||||
|
||||
|
||||
@app.route('/api/discover/diagnose', methods=['GET'])
|
||||
def diagnose_discover_data():
|
||||
"""
|
||||
|
|
@ -43662,6 +44088,66 @@ def diagnose_discover_data():
|
|||
# SEASONAL DISCOVERY ENDPOINTS
|
||||
# ========================================
|
||||
|
||||
@app.route('/api/discover/seasonal/current-playlist', methods=['GET'])
|
||||
def get_current_seasonal_playlist():
|
||||
"""Auto-detect current season and return its playlist tracks"""
|
||||
try:
|
||||
from core.seasonal_discovery import get_seasonal_discovery_service, SEASONAL_CONFIG
|
||||
|
||||
database = get_database()
|
||||
seasonal_service = get_seasonal_discovery_service(spotify_client, database)
|
||||
current_season = seasonal_service.get_current_season()
|
||||
|
||||
if not current_season or current_season not in SEASONAL_CONFIG:
|
||||
return jsonify({"success": True, "tracks": []})
|
||||
|
||||
active_source = _get_active_discovery_source()
|
||||
track_ids = seasonal_service.get_curated_seasonal_playlist(current_season, source=active_source)
|
||||
|
||||
if not track_ids:
|
||||
return jsonify({"success": True, "tracks": []})
|
||||
|
||||
track_id_col = 'spotify_track_id' if active_source == 'spotify' else 'itunes_track_id'
|
||||
tracks = []
|
||||
with database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
for track_id in track_ids:
|
||||
cursor.execute("""
|
||||
SELECT spotify_track_id, track_name, artist_name, album_name,
|
||||
album_cover_url, duration_ms, popularity, track_data_json
|
||||
FROM seasonal_tracks WHERE spotify_track_id = ? AND source = ?
|
||||
""", (track_id, active_source))
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
cursor.execute(f"""
|
||||
SELECT {track_id_col} as spotify_track_id, track_name, artist_name, album_name,
|
||||
album_cover_url, duration_ms, popularity, track_data_json
|
||||
FROM discovery_pool WHERE {track_id_col} = ? AND source = ?
|
||||
""", (track_id, active_source))
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
track_dict = dict(result)
|
||||
if track_dict.get('track_data_json'):
|
||||
try:
|
||||
import json
|
||||
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
||||
except:
|
||||
pass
|
||||
tracks.append(track_dict)
|
||||
|
||||
config = SEASONAL_CONFIG[current_season]
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"season": current_season,
|
||||
"name": config['name'],
|
||||
"tracks": tracks
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting current seasonal playlist: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/seasonal/current', methods=['GET'])
|
||||
def get_current_seasonal_content():
|
||||
"""Auto-detect and return current season's content"""
|
||||
|
|
@ -46248,7 +46734,8 @@ def _get_lb_discover_playlists(playlist_type):
|
|||
"title": playlist['title'],
|
||||
"creator": playlist['creator'],
|
||||
"annotation": playlist.get('annotation', {}),
|
||||
"track": []
|
||||
"track": [],
|
||||
"track_count": playlist.get('track_count', 0),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -843,8 +843,7 @@
|
|||
<div class="sync-header-row">
|
||||
<div>
|
||||
<h2 class="sync-title"><img src="/static/sync.png" class="page-header-icon" alt=""><span>Playlist Sync</span></h2>
|
||||
<p class="sync-subtitle">Synchronize your Spotify, Tidal, and YouTube playlists with your media
|
||||
server</p>
|
||||
<p class="sync-subtitle">Sync playlists from Spotify, Tidal, YouTube, Beatport, Deezer, and ListenBrainz to your media server</p>
|
||||
</div>
|
||||
<button class="sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
|
||||
</div>
|
||||
|
|
@ -880,6 +879,9 @@
|
|||
<button class="sync-tab-button" data-tab="beatport">
|
||||
<span class="tab-icon beatport-icon"></span> Beatport
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="discover">
|
||||
<span class="tab-icon discover-icon"></span> SoulSync Discover
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="import-file">
|
||||
<span class="tab-icon import-file-icon"></span> Import
|
||||
</button>
|
||||
|
|
@ -1753,6 +1755,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SoulSync Discover Tab Content -->
|
||||
<div class="sync-tab-content" id="discover-tab-content">
|
||||
<div class="playlist-header">
|
||||
<h3>SoulSync Discover</h3>
|
||||
<p class="discover-sync-subtitle">Playlists generated from your Discover page. Toggle "Keep it updated" to auto-sync when playlists refresh.</p>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="discover-sync-playlist-container">
|
||||
<div class="playlist-placeholder">Loading Discover playlists...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Playlist Manager Tab -->
|
||||
<div class="sync-tab-content active" id="server-tab-content">
|
||||
<div class="playlist-header">
|
||||
|
|
|
|||
|
|
@ -2330,8 +2330,17 @@ function startDecadeSyncPolling(decade, virtualPlaylistId) {
|
|||
delete _syncProgressCallbacks[virtualPlaylistId];
|
||||
const syncButton = el(`decade-${decade}-sync-btn`);
|
||||
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
|
||||
showToast(`${decade}s Classics sync complete!`, 'success');
|
||||
setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
|
||||
const _m2 = progress.matched_tracks || matched || 0;
|
||||
const _t2 = progress.total_tracks || total || 0;
|
||||
const _miss2 = _t2 - _m2;
|
||||
if (_miss2 > 0) {
|
||||
showToast(`${decade}s Classics: ${_m2}/${_t2} matched, ${_miss2} missing`, 'warning');
|
||||
} else {
|
||||
showToast(`${decade}s Classics: all ${_t2} tracks matched!`, 'success');
|
||||
}
|
||||
if (el(`decade-${decade}-sync-percentage`)) el(`decade-${decade}-sync-percentage`).textContent = '100';
|
||||
if (el(`decade-${decade}-sync-pending`)) el(`decade-${decade}-sync-pending`).textContent = '0';
|
||||
setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -2373,12 +2382,19 @@ function startDecadeSyncPolling(decade, virtualPlaylistId) {
|
|||
syncButton.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
showToast(`${decade}s Classics sync complete!`, 'success');
|
||||
const missing = total - matched;
|
||||
if (missing > 0) {
|
||||
showToast(`${decade}s Classics: ${matched}/${total} matched, ${missing} missing`, 'warning');
|
||||
} else {
|
||||
showToast(`${decade}s Classics: all ${total} tracks matched!`, 'success');
|
||||
}
|
||||
|
||||
if (percentageEl) percentageEl.textContent = '100';
|
||||
if (pendingEl) pendingEl.textContent = '0';
|
||||
setTimeout(() => {
|
||||
const statusDisplay = document.getElementById(`decade-${decade}-sync-status`);
|
||||
if (statusDisplay) statusDisplay.style.display = 'none';
|
||||
}, 3000);
|
||||
}, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error polling sync status for decade ${decade}:`, error);
|
||||
|
|
@ -2731,8 +2747,17 @@ function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) {
|
|||
delete _syncProgressCallbacks[virtualPlaylistId];
|
||||
const syncButton = el(`genre-${genreId}-sync-btn`);
|
||||
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
|
||||
showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success');
|
||||
setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
|
||||
const _m3 = progress.matched_tracks || matched || 0;
|
||||
const _t3 = progress.total_tracks || total || 0;
|
||||
const _miss3 = _t3 - _m3;
|
||||
if (_miss3 > 0) {
|
||||
showToast(`${capitalizeGenre(genreName)} Mix: ${_m3}/${_t3} matched, ${_miss3} missing`, 'warning');
|
||||
} else {
|
||||
showToast(`${capitalizeGenre(genreName)} Mix: all ${_t3} tracks matched!`, 'success');
|
||||
}
|
||||
if (el(`genre-${genreId}-sync-percentage`)) el(`genre-${genreId}-sync-percentage`).textContent = '100';
|
||||
if (el(`genre-${genreId}-sync-pending`)) el(`genre-${genreId}-sync-pending`).textContent = '0';
|
||||
setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -2774,12 +2799,19 @@ function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) {
|
|||
syncButton.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success');
|
||||
const missing = total - matched;
|
||||
if (missing > 0) {
|
||||
showToast(`${capitalizeGenre(genreName)} Mix: ${matched}/${total} matched, ${missing} missing`, 'warning');
|
||||
} else {
|
||||
showToast(`${capitalizeGenre(genreName)} Mix: all ${total} tracks matched!`, 'success');
|
||||
}
|
||||
|
||||
if (percentageEl) percentageEl.textContent = '100';
|
||||
if (pendingEl) pendingEl.textContent = '0';
|
||||
setTimeout(() => {
|
||||
const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`);
|
||||
if (statusDisplay) statusDisplay.style.display = 'none';
|
||||
}, 3000);
|
||||
}, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error polling sync status for genre ${genreName}:`, error);
|
||||
|
|
@ -7993,8 +8025,18 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) {
|
|||
'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle',
|
||||
'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist'
|
||||
};
|
||||
showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success');
|
||||
setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
|
||||
const dn = playlistNames[playlistType] || playlistType;
|
||||
const _m = progress.matched_tracks || matched || 0;
|
||||
const _t = progress.total_tracks || total || 0;
|
||||
const _miss = _t - _m;
|
||||
if (_miss > 0) {
|
||||
showToast(`${dn}: ${_m}/${_t} matched, ${_miss} missing`, 'warning');
|
||||
} else {
|
||||
showToast(`${dn}: all ${_t} tracks matched!`, 'success');
|
||||
}
|
||||
if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = '100';
|
||||
if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = '0';
|
||||
setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -8061,15 +8103,22 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) {
|
|||
'build_playlist': 'Custom Playlist'
|
||||
};
|
||||
const displayName = playlistNames[playlistType] || playlistType;
|
||||
showToast(`${displayName} sync complete!`, 'success');
|
||||
const missing = total - matched;
|
||||
if (missing > 0) {
|
||||
showToast(`${displayName}: ${matched}/${total} matched, ${missing} missing`, 'warning');
|
||||
} else {
|
||||
showToast(`${displayName}: all ${total} tracks matched!`, 'success');
|
||||
}
|
||||
|
||||
// Hide status display after 3 seconds
|
||||
// Update status display to show final result, then hide after 5s
|
||||
if (percentageEl) percentageEl.textContent = '100';
|
||||
if (pendingEl) pendingEl.textContent = '0';
|
||||
setTimeout(() => {
|
||||
const statusDisplay = document.getElementById(`${prefix}-sync-status`);
|
||||
if (statusDisplay) {
|
||||
statusDisplay.style.display = 'none';
|
||||
}
|
||||
}, 3000);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -8919,3 +8968,546 @@ if (document.readyState === 'loading') {
|
|||
|
||||
// ============================================================================
|
||||
|
||||
|
||||
// ── SoulSync Discover Sync Tab ─────────────────────────────────────────
|
||||
|
||||
async function loadDiscoverSyncPlaylists() {
|
||||
if (discoverSyncPlaylistsLoaded) return;
|
||||
discoverSyncPlaylistsLoaded = true;
|
||||
const container = document.getElementById('discover-sync-playlist-container');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="playlist-placeholder">Loading Discover playlists...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/discover/synced-playlists');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success || !data.playlists || data.playlists.length === 0) {
|
||||
container.innerHTML = '<div class="playlist-placeholder">No Discover playlists available. Visit the Discover page to generate playlists first.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// Show source info and empty-state hint if no playlists have data
|
||||
if (!data.has_data) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'discover-sync-empty-hint';
|
||||
hint.innerHTML = `
|
||||
<p>Your Discover playlists don't have any tracks yet.</p>
|
||||
<p>Go to the <strong>Discover</strong> page and let it build your playlist pool — it uses your <strong>${data.source_label || 'configured source'}</strong> data and watchlist to generate personalized playlists.</p>
|
||||
`;
|
||||
container.appendChild(hint);
|
||||
}
|
||||
|
||||
data.playlists.forEach(playlist => {
|
||||
renderDiscoverSyncCard(playlist, container, data.source_label || data.source);
|
||||
// Resume polling if there's an active batch for this playlist
|
||||
if (playlist.active_batch_id && playlist.sync_status === 'syncing') {
|
||||
const btn = document.getElementById(`discover-sync-btn-${playlist.type}`);
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
|
||||
pollDiscoverBatchFromTab(playlist.type, playlist.active_batch_id, playlist.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Also fetch ListenBrainz playlists and add them
|
||||
try {
|
||||
// Fetch saved auto-update settings so LB toggles persist across restarts
|
||||
let lbAutoSettings = {};
|
||||
try {
|
||||
const settingsRes = await fetch('/api/discover/auto-update');
|
||||
if (settingsRes.ok) {
|
||||
const settingsData = await settingsRes.json();
|
||||
if (settingsData.success) lbAutoSettings = settingsData.settings || {};
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const lbRes = await fetch('/api/discover/listenbrainz/created-for');
|
||||
if (lbRes.ok) {
|
||||
const lbData = await lbRes.json();
|
||||
if (lbData.success && lbData.playlists && lbData.playlists.length > 0) {
|
||||
// Fetch sync history once for all LB playlists
|
||||
let historyEntries = [];
|
||||
try {
|
||||
const histRes = await fetch('/api/sync/history?source=discover&limit=50');
|
||||
if (histRes.ok) {
|
||||
const histData = await histRes.json();
|
||||
historyEntries = histData.entries || [];
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Deduplicate by base name — only show the latest of each type
|
||||
const seen = new Map();
|
||||
for (const p of lbData.playlists) {
|
||||
const pl = p.playlist || p;
|
||||
const rawTitle = pl.title || 'ListenBrainz Playlist';
|
||||
// Strip ", week of YYYY-MM-DD ..." suffix for a stable display name
|
||||
const baseName = rawTitle.replace(/,\s*week of .+$/i, '').trim();
|
||||
// Keep only the first (latest) for each base name
|
||||
if (!seen.has(baseName)) seen.set(baseName, { pl, rawTitle, baseName });
|
||||
}
|
||||
|
||||
for (const { pl, rawTitle, baseName } of seen.values()) {
|
||||
const identifier = pl.identifier || '';
|
||||
const mbid = identifier.split('/').pop();
|
||||
const trackCount = pl.track_count || (pl.track || []).length;
|
||||
// Determine icon from title
|
||||
let icon = '🧠';
|
||||
if (rawTitle.toLowerCase().includes('jam')) icon = '🎸';
|
||||
else if (rawTitle.toLowerCase().includes('explor')) icon = '🔭';
|
||||
|
||||
const lbType = `listenbrainz_${mbid}`;
|
||||
|
||||
// Check sync history for this playlist by matching the base name
|
||||
let syncStatus = 'never';
|
||||
let lastSynced = null;
|
||||
let matchedTracks = 0;
|
||||
let totalSyncTracks = 0;
|
||||
for (const entry of historyEntries) {
|
||||
const eName = entry.playlist_name || '';
|
||||
if (eName === baseName || eName.startsWith(baseName)) {
|
||||
syncStatus = 'synced';
|
||||
lastSynced = entry.completed_at || entry.started_at || entry.created_at;
|
||||
matchedTracks = entry.tracks_found || 0;
|
||||
totalSyncTracks = entry.total_tracks || 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderDiscoverSyncCard({
|
||||
type: lbType,
|
||||
name: baseName,
|
||||
description: '',
|
||||
icon: icon,
|
||||
track_count: trackCount,
|
||||
sync_status: syncStatus,
|
||||
last_synced: lastSynced,
|
||||
matched_tracks: matchedTracks,
|
||||
total_sync_tracks: totalSyncTracks,
|
||||
auto_update: !!lbAutoSettings[lbType],
|
||||
virtual_id: `discover_listenbrainz_${mbid}`,
|
||||
_lb_mbid: mbid,
|
||||
}, container, 'ListenBrainz');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (lbErr) {
|
||||
console.warn('Could not load ListenBrainz playlists for discover sync tab:', lbErr);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading discover sync playlists:', error);
|
||||
container.innerHTML = '<div class="playlist-placeholder">Error loading Discover playlists.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiscoverSyncCard(playlist, container, sourceLabel) {
|
||||
const card = document.createElement('div');
|
||||
const isEmpty = playlist.track_count === 0;
|
||||
card.className = `discover-sync-card${isEmpty ? ' discover-sync-card-empty' : ''}`;
|
||||
card.id = `discover-sync-card-${playlist.type}`;
|
||||
|
||||
const lastSyncedText = playlist.last_synced
|
||||
? `Last synced ${timeAgo(playlist.last_synced)}`
|
||||
: 'Never synced';
|
||||
|
||||
const statusClass = playlist.sync_status === 'syncing' ? 'syncing' :
|
||||
playlist.sync_status === 'synced' ? 'synced' : 'not-synced';
|
||||
let statusText = playlist.sync_status === 'syncing' ? 'Syncing...' :
|
||||
playlist.sync_status === 'synced' ? 'Synced' : 'Not synced';
|
||||
|
||||
// Show matched/total counts if available (only when matched > 0, meaning completion was recorded)
|
||||
if (playlist.sync_status === 'synced' && playlist.matched_tracks > 0 && playlist.total_sync_tracks > 0) {
|
||||
statusText = `Synced ${playlist.matched_tracks}/${playlist.total_sync_tracks}`;
|
||||
}
|
||||
|
||||
const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="discover-sync-card-icon">${playlist.icon}</div>
|
||||
<div class="discover-sync-card-info">
|
||||
<div class="discover-sync-card-name">${playlist.name}
|
||||
<span class="discover-sync-card-meta-inline">
|
||||
<span class="discover-sync-source-badge">${sourceLabel || 'unknown'}</span>
|
||||
<span class="discover-sync-separator">\u00b7</span>
|
||||
<span class="discover-sync-track-count">${trackLabel}</span>
|
||||
<span class="discover-sync-separator">\u00b7</span>
|
||||
<span class="discover-sync-status ${statusClass}">${statusText}</span>
|
||||
<span class="discover-sync-separator">\u00b7</span>
|
||||
<span class="discover-sync-last-synced">${lastSyncedText}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-sync-card-actions">
|
||||
<div class="discover-sync-toggle-wrapper" title="${isEmpty ? 'No tracks available — visit Discover first' : 'Keep this playlist updated automatically'}">
|
||||
<label class="discover-sync-toggle-label">Keep updated</label>
|
||||
<label class="discover-sync-toggle">
|
||||
<input type="checkbox" ${playlist.auto_update ? 'checked' : ''} ${isEmpty ? 'disabled' : ''}
|
||||
onchange="toggleDiscoverAutoUpdate('${playlist.type}', this.checked)">
|
||||
<span class="discover-sync-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="discover-sync-btn" id="discover-sync-btn-${playlist.type}"
|
||||
onclick="syncDiscoverPlaylistFromTab('${playlist.type}', '${playlist.name}')"
|
||||
${playlist.sync_status === 'syncing' || isEmpty ? 'disabled' : ''}>
|
||||
\u27f3 Sync Now
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Make the icon + info area clickable to view tracks
|
||||
if (!isEmpty) {
|
||||
const clickArea = card.querySelector('.discover-sync-card-info');
|
||||
const iconArea = card.querySelector('.discover-sync-card-icon');
|
||||
[clickArea, iconArea].forEach(el => {
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('click', () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon));
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
async function toggleDiscoverAutoUpdate(playlistType, enabled) {
|
||||
try {
|
||||
const response = await fetch('/api/discover/auto-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playlist_type: playlistType, enabled: enabled })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(`Auto-update ${enabled ? 'enabled' : 'disabled'} for ${playlistType.replace(/_/g, ' ')}`, 'success');
|
||||
} else {
|
||||
showToast(`Failed to update setting: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling auto-update:', error);
|
||||
showToast('Failed to update setting', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const _discoverSyncQueue = [];
|
||||
let _discoverSyncRunning = false;
|
||||
|
||||
async function syncDiscoverPlaylistFromTab(playlistType, playlistName) {
|
||||
// Serialize sync operations to avoid concurrent backend contention
|
||||
return new Promise((resolve) => {
|
||||
_discoverSyncQueue.push({ playlistType, playlistName, resolve });
|
||||
_processDiscoverSyncQueue();
|
||||
});
|
||||
}
|
||||
|
||||
async function _processDiscoverSyncQueue() {
|
||||
if (_discoverSyncRunning || _discoverSyncQueue.length === 0) return;
|
||||
_discoverSyncRunning = true;
|
||||
const { playlistType, playlistName, resolve } = _discoverSyncQueue.shift();
|
||||
try {
|
||||
await _doSyncDiscoverPlaylist(playlistType, playlistName);
|
||||
} finally {
|
||||
_discoverSyncRunning = false;
|
||||
resolve();
|
||||
_processDiscoverSyncQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
|
||||
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Syncing...';
|
||||
}
|
||||
|
||||
try {
|
||||
let tracksResponse;
|
||||
|
||||
// Use unified URL helper (handles ListenBrainz + standard discover types)
|
||||
const apiUrl = _discoverPlaylistApiUrl(playlistType);
|
||||
if (apiUrl) {
|
||||
tracksResponse = await fetch(apiUrl);
|
||||
}
|
||||
|
||||
let tracks = [];
|
||||
if (tracksResponse && tracksResponse.ok) {
|
||||
const data = await tracksResponse.json();
|
||||
tracks = data.tracks || [];
|
||||
}
|
||||
|
||||
if (!tracks.length) {
|
||||
showToast(`No tracks available for ${playlistName}. Visit the Discover page first.`, 'warning');
|
||||
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
|
||||
return;
|
||||
}
|
||||
|
||||
const syncTracks = tracks.map(track => {
|
||||
if (track.track_data_json) {
|
||||
const t = track.track_data_json;
|
||||
if (t.artists && Array.isArray(t.artists)) {
|
||||
t.artists = t.artists.map(a => a.name || a);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
return {
|
||||
id: track.spotify_track_id || track.track_id || '',
|
||||
name: track.track_name || track.name || '',
|
||||
artists: [track.artist_name || 'Unknown Artist'],
|
||||
album: track.album_name || '',
|
||||
duration_ms: track.duration_ms || 0,
|
||||
image_url: track.album_cover_url || track.image_url || ''
|
||||
};
|
||||
});
|
||||
|
||||
const virtualPlaylistId = `discover_${playlistType}`;
|
||||
|
||||
// Use the download batch endpoint directly so the batch is labeled
|
||||
// as "Discover" instead of going through sync → wishlist → "Wishlist" batch.
|
||||
// Omit force_download_all so it checks the library first and only downloads missing tracks.
|
||||
const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tracks: syncTracks,
|
||||
playlist_name: playlistName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await batchResponse.json();
|
||||
if (result.success) {
|
||||
showToast(`Downloading ${playlistName} (${syncTracks.length} tracks)...`, 'info');
|
||||
const card = document.getElementById(`discover-sync-card-${playlistType}`);
|
||||
if (card) {
|
||||
const statusEl = card.querySelector('.discover-sync-status');
|
||||
if (statusEl) {
|
||||
statusEl.className = 'discover-sync-status syncing';
|
||||
statusEl.textContent = 'Downloading...';
|
||||
}
|
||||
}
|
||||
// Poll the download batch status
|
||||
pollDiscoverBatchFromTab(playlistType, result.batch_id, playlistName);
|
||||
} else {
|
||||
showToast(`Download failed: ${result.error || 'Unknown error'}`, 'error');
|
||||
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error syncing ${playlistName}:`, error);
|
||||
showToast(`Failed to sync ${playlistName}`, 'error');
|
||||
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
|
||||
}
|
||||
}
|
||||
|
||||
function pollDiscoverSyncFromTab(playlistType, virtualPlaylistId, playlistName) {
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/sync/status/${virtualPlaylistId}`);
|
||||
if (!resp.ok) { clearInterval(pollInterval); return; }
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.status === 'finished' || data.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
|
||||
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
|
||||
|
||||
const card = document.getElementById(`discover-sync-card-${playlistType}`);
|
||||
if (card) {
|
||||
const statusEl = card.querySelector('.discover-sync-status');
|
||||
if (statusEl) {
|
||||
if (data.status === 'finished') {
|
||||
const progress = data.progress || data.result || {};
|
||||
const matched = progress.matched_tracks || 0;
|
||||
const total = progress.total_tracks || 0;
|
||||
statusEl.className = 'discover-sync-status synced';
|
||||
statusEl.textContent = matched > 0 && total > 0 ? `Synced ${matched}/${total}` : 'Synced';
|
||||
} else {
|
||||
statusEl.className = 'discover-sync-status not-synced';
|
||||
statusEl.textContent = 'Failed';
|
||||
}
|
||||
}
|
||||
const lastSyncedEl = card.querySelector('.discover-sync-last-synced');
|
||||
if (lastSyncedEl && data.status === 'finished') {
|
||||
lastSyncedEl.textContent = 'Last synced just now';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status === 'finished') {
|
||||
showToast(`${playlistName} synced successfully!`, 'success');
|
||||
} else {
|
||||
showToast(`${playlistName} sync failed`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) {
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/playlists/${batchId}/download_status`);
|
||||
if (!resp.ok) { clearInterval(pollInterval); return; }
|
||||
const data = await resp.json();
|
||||
const phase = data.phase || data.status;
|
||||
|
||||
if (phase === 'complete' || phase === 'error' || phase === 'cancelled') {
|
||||
clearInterval(pollInterval);
|
||||
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
|
||||
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
|
||||
|
||||
// Extract matched/total from analysis_results
|
||||
const analysisResults = data.analysis_results || [];
|
||||
const totalTracks = analysisResults.length;
|
||||
const matchedTracks = analysisResults.filter(r => r.found).length;
|
||||
const tasks = data.tasks || [];
|
||||
const downloaded = tasks.filter(t => t.status === 'completed').length;
|
||||
const failed = tasks.filter(t => t.status === 'failed' || t.status === 'not_found').length;
|
||||
|
||||
const card = document.getElementById(`discover-sync-card-${playlistType}`);
|
||||
const syncedCount = matchedTracks + downloaded;
|
||||
if (card) {
|
||||
const statusEl = card.querySelector('.discover-sync-status');
|
||||
if (statusEl) {
|
||||
statusEl.className = `discover-sync-status ${phase === 'complete' ? 'synced' : 'not-synced'}`;
|
||||
if (phase === 'complete' && totalTracks > 0) {
|
||||
statusEl.textContent = `Synced ${syncedCount}/${totalTracks}`;
|
||||
} else {
|
||||
statusEl.textContent = phase === 'complete' ? 'Synced' : (phase === 'cancelled' ? 'Cancelled' : 'Failed');
|
||||
}
|
||||
}
|
||||
const lastSyncedEl = card.querySelector('.discover-sync-last-synced');
|
||||
if (lastSyncedEl && phase === 'complete') {
|
||||
lastSyncedEl.textContent = 'Last synced just now';
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === 'complete') {
|
||||
if (totalTracks > 0) {
|
||||
const missing = totalTracks - syncedCount;
|
||||
let msg = `${playlistName}: ${syncedCount}/${totalTracks} in library`;
|
||||
if (downloaded > 0) msg += `, ${downloaded} downloaded`;
|
||||
if (failed > 0) msg += `, ${failed} failed`;
|
||||
if (missing === 0) msg += ' - all owned!';
|
||||
showToast(msg, 'success');
|
||||
} else {
|
||||
showToast(`${playlistName} download complete!`, 'success');
|
||||
}
|
||||
} else if (phase !== 'cancelled') {
|
||||
showToast(`${playlistName} download failed`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a discover playlist type to its API endpoint for fetching tracks.
|
||||
*/
|
||||
function _discoverPlaylistApiUrl(playlistType) {
|
||||
// ListenBrainz playlists
|
||||
if (playlistType.startsWith('listenbrainz_')) {
|
||||
const mbid = playlistType.replace('listenbrainz_', '');
|
||||
return `/api/discover/listenbrainz/playlist/${mbid}`;
|
||||
}
|
||||
const map = {
|
||||
release_radar: '/api/discover/release-radar',
|
||||
discovery_weekly: '/api/discover/weekly',
|
||||
seasonal_playlist: '/api/discover/seasonal/current-playlist',
|
||||
popular_picks: '/api/discover/personalized/popular-picks',
|
||||
hidden_gems: '/api/discover/personalized/hidden-gems',
|
||||
discovery_shuffle: '/api/discover/personalized/discovery-shuffle',
|
||||
familiar_favorites: '/api/discover/personalized/familiar-favorites',
|
||||
};
|
||||
return map[playlistType] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal showing all tracks in a Discover playlist (mirrored-modal style).
|
||||
*/
|
||||
async function openDiscoverPlaylistModal(playlistType, playlistName, icon) {
|
||||
const apiUrl = _discoverPlaylistApiUrl(playlistType);
|
||||
if (!apiUrl) { showToast('Unknown playlist type', 'error'); return; }
|
||||
|
||||
showLoadingOverlay(`Loading ${playlistName}...`);
|
||||
try {
|
||||
const res = await fetch(apiUrl);
|
||||
const data = await res.json();
|
||||
const tracks = data.tracks || [];
|
||||
|
||||
hideLoadingOverlay();
|
||||
|
||||
if (!tracks.length) {
|
||||
showToast(`No tracks in ${playlistName}. Visit the Discover page first.`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing modal
|
||||
const old = document.getElementById('discover-playlist-modal');
|
||||
if (old) old.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'discover-playlist-modal';
|
||||
overlay.className = 'mirrored-modal-overlay';
|
||||
|
||||
const trackRows = tracks.map((t, idx) => {
|
||||
const name = t.track_name || t.name || '';
|
||||
const artist = t.artist_name || (t.artists ? (Array.isArray(t.artists) ? t.artists.map(a => a.name || a).join(', ') : t.artists) : '');
|
||||
const album = t.album_name || t.album || '';
|
||||
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
|
||||
const coverUrl = t.album_cover_url || '';
|
||||
const coverHtml = coverUrl
|
||||
? `<img src="${_escAttr(coverUrl)}" alt="" class="discover-modal-track-img" loading="lazy">`
|
||||
: `<div class="discover-modal-track-img-placeholder"></div>`;
|
||||
return `<div class="mirrored-track-row">
|
||||
<span class="track-pos">${idx + 1}</span>
|
||||
<span class="track-cover">${coverHtml}</span>
|
||||
<span class="track-title">${_esc(name)}</span>
|
||||
<span class="track-artist">${_esc(artist)}</span>
|
||||
<span class="track-album">${_esc(album)}</span>
|
||||
<span class="track-duration">${dur}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="mirrored-modal">
|
||||
<div class="mirrored-modal-header">
|
||||
<div class="mirrored-modal-hero">
|
||||
<div class="mirrored-modal-hero-icon discover">${icon || '🎵'}</div>
|
||||
<div class="mirrored-modal-hero-info">
|
||||
<h2 class="mirrored-modal-hero-title">${_esc(playlistName)}</h2>
|
||||
<div class="mirrored-modal-hero-subtitle">
|
||||
<span class="mirrored-modal-hero-badge">discover</span>
|
||||
<span>${tracks.length} tracks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="mirrored-modal-close" onclick="closeDiscoverPlaylistModal()">×</span>
|
||||
</div>
|
||||
<div class="mirrored-modal-tracks">
|
||||
<div class="mirrored-track-header">
|
||||
<span>#</span><span></span><span>Track</span><span>Artist</span><span>Album</span><span style="text-align:right">Time</span>
|
||||
</div>
|
||||
${trackRows}
|
||||
</div>
|
||||
<div class="mirrored-modal-footer">
|
||||
<div class="mirrored-modal-footer-left"></div>
|
||||
<div class="mirrored-modal-footer-right" style="display:flex;gap:10px;">
|
||||
<button class="mirrored-btn-close" onclick="closeDiscoverPlaylistModal()">Close</button>
|
||||
<button class="mirrored-btn-discover" onclick="closeDiscoverPlaylistModal(); syncDiscoverPlaylistFromTab('${_escAttr(playlistType)}', '${_escAttr(playlistName)}')">Sync Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) closeDiscoverPlaylistModal(); });
|
||||
document.body.appendChild(overlay);
|
||||
} catch (err) {
|
||||
hideLoadingOverlay();
|
||||
showToast(`Error loading ${playlistName}: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDiscoverPlaylistModal() {
|
||||
const m = document.getElementById('discover-playlist-modal');
|
||||
if (m) m.remove();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2514,7 +2514,12 @@ function _adlRenderBatchPanel() {
|
|||
phaseText = `${batch.completed}/${total} tracks`;
|
||||
if (batch.active > 0) phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
|
||||
} else if (batch.phase === 'complete') {
|
||||
phaseText = `Done \u2014 ${batch.completed} tracks`;
|
||||
const analysisTotal = batch.analysis_total || 0;
|
||||
const alreadyOwned = analysisTotal > 0 ? analysisTotal - total : 0;
|
||||
let parts = [`${batch.completed} downloaded`];
|
||||
if (alreadyOwned > 0) parts.push(`${alreadyOwned} owned`);
|
||||
if (batch.failed > 0) parts.push(`${batch.failed} failed`);
|
||||
phaseText = parts.join(', ');
|
||||
phaseIcon = '<span style="color:#22c55e;margin-right:4px">\u2713</span>';
|
||||
} else if (batch.phase === 'cancelled') {
|
||||
phaseText = 'Cancelled';
|
||||
|
|
|
|||
|
|
@ -2122,6 +2122,7 @@ function importFileSubmit() {
|
|||
// ── Mirrored Playlists ────────────────────────────────────────────────
|
||||
|
||||
let mirroredPlaylistsLoaded = false;
|
||||
let discoverSyncPlaylistsLoaded = false;
|
||||
|
||||
/**
|
||||
* Fire-and-forget helper: send parsed playlist data to be mirrored on the backend.
|
||||
|
|
|
|||
|
|
@ -2482,7 +2482,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.helper-first-launch-tip {
|
||||
position: fixed;
|
||||
bottom: 34px;
|
||||
right: 84px;
|
||||
right: 136px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(16, 16, 16, 0.95);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||
|
|
@ -11913,6 +11913,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
overflow: hidden;
|
||||
border-top-left-radius: 20px;
|
||||
border-top-right-radius: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mirrored-modal-hero {
|
||||
|
|
@ -12113,6 +12114,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mirrored-modal-footer button {
|
||||
|
|
@ -59312,3 +59314,313 @@ body.reduce-effects *::after {
|
|||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── SoulSync Discover Sync Tab ───────────────────────────────────────── */
|
||||
|
||||
.discover-icon {
|
||||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23a78bfa"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm-5-9l7.5-4.5L12 14l-5-3z"/></svg>');
|
||||
}
|
||||
|
||||
.sync-tab-button[data-tab="discover"].active {
|
||||
background: linear-gradient(135deg, #a78bfa, #7c3aed);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px rgba(167, 139, 250, 0.3);
|
||||
}
|
||||
|
||||
.sync-tab-button.active .discover-icon {
|
||||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm-5-9l7.5-4.5L12 14l-5-3z"/></svg>');
|
||||
}
|
||||
|
||||
.discover-sync-subtitle {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 13px;
|
||||
margin: 4px 0 0 0;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.discover-sync-card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
margin: 3px 6px;
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.discover-sync-card:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(167, 139, 250, 0.2);
|
||||
}
|
||||
|
||||
.discover-sync-card-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.discover-sync-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.discover-sync-card-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.discover-sync-card-meta-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.discover-sync-source-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.2);
|
||||
}
|
||||
|
||||
.discover-sync-card-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.discover-sync-card-meta {
|
||||
display: none;
|
||||
}
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.discover-sync-separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.discover-sync-track-count {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.discover-sync-status {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.discover-sync-status.synced {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.discover-sync-status.syncing {
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.discover-sync-status.not-synced {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.discover-sync-last-synced {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.discover-sync-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discover-sync-toggle-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.discover-sync-toggle-label {
|
||||
font-size: 10.5px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.discover-sync-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.discover-sync-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.discover-sync-toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 22px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.discover-sync-toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.discover-sync-toggle input:checked + .discover-sync-toggle-slider {
|
||||
background: #a78bfa;
|
||||
}
|
||||
|
||||
.discover-sync-toggle input:checked + .discover-sync-toggle-slider::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.discover-sync-btn {
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #a78bfa, #7c3aed);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.discover-sync-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(167, 139, 250, 0.35);
|
||||
}
|
||||
|
||||
.discover-sync-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Empty-state styling */
|
||||
.discover-sync-card-empty {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.discover-sync-card-empty .discover-sync-toggle-slider {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.discover-sync-empty-hint {
|
||||
background: rgba(255, 193, 7, 0.08);
|
||||
border: 1px solid rgba(255, 193, 7, 0.25);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.discover-sync-empty-hint p {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.discover-sync-empty-hint p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.discover-sync-empty-hint strong {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.discover-sync-source-info {
|
||||
color: #888;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.discover-sync-source-info strong {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.discover-sync-card {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.discover-sync-card-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Discover playlist modal extras */
|
||||
.mirrored-modal-hero-icon.discover {
|
||||
background: linear-gradient(135deg, rgba(167, 139, 250, 0.2) 0%, rgba(124, 58, 237, 0.12) 100%);
|
||||
border-color: rgba(167, 139, 250, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(167, 139, 250, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.discover-modal-track-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.discover-modal-track-img-placeholder {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.track-cover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#discover-playlist-modal .mirrored-track-header,
|
||||
#discover-playlist-modal .mirrored-track-row {
|
||||
grid-template-columns: 40px 40px 1.4fr 1.2fr 1fr 56px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2784,6 +2784,11 @@ function initializeSyncPage() {
|
|||
loadMirroredPlaylists();
|
||||
}
|
||||
|
||||
// Auto-load SoulSync Discover playlists on first tab activation
|
||||
if (tabId === 'discover' && !discoverSyncPlaylistsLoaded) {
|
||||
loadDiscoverSyncPlaylists();
|
||||
}
|
||||
|
||||
// Auto-load server playlists on first tab activation
|
||||
if (tabId === 'server' && !window._serverPlaylistsLoaded) {
|
||||
window._serverPlaylistsLoaded = true;
|
||||
|
|
|
|||
Loading…
Reference in a new issue