Merge remote-tracking branch 'origin/feat/discover-sync-tab' into dev

This commit is contained in:
JohnBaumb 2026-04-23 11:13:15 -07:00
commit 055c2c25c5
11 changed files with 1763 additions and 125 deletions

View file

@ -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():

View file

@ -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 = []

View file

@ -636,7 +636,8 @@ class MusicDatabase:
playlist_folder_mode INTEGER DEFAULT 0,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
track_results TEXT
track_results TEXT,
server_push_status TEXT
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_started_at ON sync_history (started_at DESC)")
@ -662,6 +663,16 @@ class MusicDatabase:
except Exception:
pass
# Migration: add server_push_status column to sync_history
try:
cursor.execute("SELECT server_push_status FROM sync_history LIMIT 1")
except Exception:
try:
cursor.execute("ALTER TABLE sync_history ADD COLUMN server_push_status TEXT")
logger.info("Added server_push_status column to sync_history table")
except Exception:
pass
# Migration: add track_artist column for per-track artist on compilations/DJ mixes
try:
cursor.execute("SELECT track_artist FROM tracks LIMIT 1")
@ -10497,6 +10508,20 @@ class MusicDatabase:
logger.debug(f"Error updating sync history track results: {e}")
return False
def update_sync_history_push_status(self, batch_id, status):
"""Update the server push status for a sync_history entry."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE sync_history SET server_push_status = ? WHERE batch_id = ?
""", (status, batch_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.debug(f"Error updating sync history push status: {e}")
return False
def refresh_sync_history_entry(self, entry_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0):
"""Update an existing sync_history entry with new stats and reset timestamps to move it to the top."""
try:
@ -10611,7 +10636,8 @@ class MusicDatabase:
cursor.execute("""
SELECT id, batch_id, playlist_name, source, sync_type, source_page,
total_tracks, tracks_found, tracks_downloaded, tracks_failed,
thumb_url, is_album_download, started_at, completed_at
thumb_url, is_album_download, started_at, completed_at,
server_push_status
FROM sync_history
WHERE completed_at IS NOT NULL
AND started_at >= datetime('now', ? || ' days')

View file

@ -28864,8 +28864,22 @@ 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 playlists to media server after downloads complete
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):
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_'):
url_hash = playlist_id.replace('youtube_', '')
if url_hash in youtube_playlist_states:
@ -31573,6 +31587,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')),
@ -32050,8 +32065,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")
@ -32074,6 +32108,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', []))
@ -32092,6 +32129,21 @@ def _check_batch_completion_v2(batch_id):
})
except Exception:
pass
# Push playlists to media server after downloads complete
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):
database.update_sync_history_push_status(batch_id, 'pending')
threading.Thread(
target=_push_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
@ -32465,7 +32517,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'),
@ -32566,6 +32618,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:
@ -32576,6 +32632,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:
@ -32615,14 +32674,136 @@ 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_playlist_to_server(batch_id, batch):
"""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 Navidrome, Plex, and Jellyfin."""
database = get_database()
try:
# Resolve the active media server client
active_server = config_manager.get_active_media_server()
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
playlist_id = batch.get('playlist_id', '')
playlist_name = batch.get('playlist_name', '')
if not playlist_name:
return
database.update_sync_history_push_status(batch_id, 'pushing')
analysis_results = batch.get('analysis_results', [])
if not analysis_results:
logger.info(f"[PlaylistPush] No analysis results for {playlist_name} - skipping server push")
database.update_sync_history_push_status(batch_id, 'skipped')
return
# Build list of tracks that should be in the playlist (found in library OR successfully downloaded)
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"[PlaylistPush] No tracks to push for {playlist_name}")
database.update_sync_history_push_status(batch_id, 'skipped')
return
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
server_client.trigger_library_scan()
# Wait for scan to finish (poll every 5s, up to 90s)
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 = []
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 {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 {active_server}")
success = server_client.update_playlist(playlist_name, matched_server_tracks)
if success:
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 {active_server}")
database.update_sync_history_push_status(batch_id, 'failed')
except Exception as e:
logger.error(f"[PlaylistPush] Error pushing playlist to server: {e}")
import traceback
traceback.print_exc()
try:
database.update_sync_history_push_status(batch_id, 'failed')
except Exception:
pass
# ===============================
# == SERVER PLAYLIST MANAGER ==
@ -33287,6 +33468,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:
@ -39429,6 +39612,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,
@ -39438,7 +39628,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:
@ -43789,6 +43979,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",
@ -43805,6 +43998,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():
"""
@ -43878,6 +44334,69 @@ 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": []})
# 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()
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"""
@ -43966,8 +44485,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 = []
@ -46464,7 +46985,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),
}
})

View file

@ -837,8 +837,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>
@ -874,6 +873,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>
@ -1747,6 +1749,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">

View file

@ -2304,8 +2304,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);
}
};
}
@ -2347,12 +2356,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);
@ -2705,8 +2721,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);
}
};
}
@ -2748,12 +2773,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);
@ -7817,88 +7849,9 @@ function checkForActiveDiscoverDownloads() {
}
async function startDiscoverPlaylistSync(playlistType, playlistName) {
console.log(`🔄 Starting sync for ${playlistName}`);
console.log(`🔄 Starting sync for ${playlistName} (fire-and-forget from Discover page)`);
// Get tracks based on playlist type
let tracks = [];
if (playlistType === 'release_radar') {
tracks = discoverReleaseRadarTracks;
} else if (playlistType === 'discovery_weekly') {
tracks = discoverWeeklyTracks;
} else if (playlistType === 'seasonal_playlist') {
tracks = discoverSeasonalTracks;
} else if (playlistType === 'popular_picks') {
tracks = personalizedPopularPicks;
} else if (playlistType === 'hidden_gems') {
tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'familiar_favorites') {
tracks = personalizedFamiliarFavorites;
} else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks;
}
if (!tracks || tracks.length === 0) {
showToast(`No tracks available for ${playlistName}`, 'warning');
return;
}
// Convert to format expected by sync API
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
// Use track_data_json if available
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
// Fallback: construct track object
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
// Normalize artists to array of strings for sync compatibility
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create virtual playlist ID
const virtualPlaylistId = `discover_${playlistType}`;
// Store in cache for sync function
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
// Create virtual playlist object
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Add to spotify playlists array if not already there
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
spotifyPlaylists.push(virtualPlaylist);
}
// Show sync status display (convert underscores to hyphens for ID)
const statusId = playlistType.replace(/_/g, '-') + '-sync-status';
const statusDisplay = document.getElementById(statusId);
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
// Disable sync button to prevent duplicate syncs (convert underscores to hyphens for ID)
// Disable the sync button on the Discover page
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
const syncButton = document.getElementById(buttonId);
if (syncButton) {
@ -7907,23 +7860,104 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) {
syncButton.style.cursor = 'not-allowed';
}
// Start sync using existing function
await startPlaylistSync(virtualPlaylistId);
// Extract image URL from first track for download bar bubble
let imageUrl = null;
if (spotifyTracks && spotifyTracks.length > 0) {
const firstTrack = spotifyTracks[0];
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
imageUrl = firstTrack.album.images[0].url;
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'; }
}
}
// Add to discover download bar
addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl);
/**
* 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; }
// Start polling for progress updates
startDiscoverSyncPolling(playlistType, virtualPlaylistId);
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 = `<span class="toast-compact-icon">${icon}</span><span class="toast-compact-msg">${_escToast(message)}</span>`;
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
@ -7967,8 +8001,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);
}
};
}
@ -8035,15 +8079,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) {
@ -8893,3 +8944,582 @@ 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>
<div class="discover-sync-toggle-wrapper" title="${isEmpty ? '' : 'Skip quality filters and download any available source'}">
<label class="discover-sync-toggle-label">Force DL</label>
<label class="discover-sync-toggle">
<input type="checkbox" id="discover-force-dl-${playlist.type}" ${isEmpty ? 'disabled' : ''}>
<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, 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, forceDownload, resolve });
_processDiscoverSyncQueue();
});
}
async function _processDiscoverSyncQueue() {
if (_discoverSyncRunning || _discoverSyncQueue.length === 0) return;
_discoverSyncRunning = true;
const { playlistType, playlistName, forceDownload, resolve } = _discoverSyncQueue.shift();
try {
await _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload);
} finally {
_discoverSyncRunning = false;
resolve();
_processDiscoverSyncQueue();
}
}
async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload) {
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.
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(bodyPayload)
});
const result = await batchResponse.json();
if (result.success) {
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');
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) {
// 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; }
const data = await resp.json();
const phase = data.phase || data.status;
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'; }
// 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);
delete discoverSyncPollers[playlistType];
}
}, 3000);
// Register so page-leave cleanup can clear it
discoverSyncPollers[playlistType] = pollInterval;
}
/**
* 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()">&times;</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();
}

View file

@ -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)) {
@ -2237,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':
@ -2246,6 +2259,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();

View file

@ -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';
@ -2633,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)) {

View file

@ -2157,6 +2157,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.

View file

@ -2490,7 +2490,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);
@ -11920,6 +11920,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 {
@ -12120,6 +12121,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 {
@ -59520,4 +59522,323 @@ 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 ───────────────────────────────────────── */
.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;
}
/* 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);
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;
}

View file

@ -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');
@ -2784,6 +2832,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;