Compare commits
30 commits
main
...
johnbaumb-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e40eb213b4 | ||
|
|
97b0ee960a | ||
|
|
150dd046df | ||
|
|
62797c68af | ||
|
|
016e8a4c16 | ||
|
|
038a0189a4 | ||
|
|
92077c9117 | ||
|
|
1f944841a3 | ||
|
|
a0eb289d63 | ||
|
|
ddae75f0f9 | ||
|
|
055c2c25c5 | ||
|
|
0fed25b51a | ||
|
|
30e3ed621a | ||
|
|
c422c30c21 | ||
|
|
d7e1cdeb83 | ||
|
|
85ea2a810f | ||
|
|
5706f32ba2 | ||
|
|
f79cb182d4 | ||
|
|
735a0ebe58 | ||
|
|
1ef1a4284e | ||
|
|
f32da04fd1 | ||
|
|
d2bd66c0ce | ||
|
|
ed013d79b9 | ||
|
|
11b74d7c14 | ||
|
|
9b3e65c3d8 | ||
|
|
332392f338 | ||
|
|
c3b9f97afc | ||
|
|
3c06e66f03 | ||
|
|
99a95dcc91 | ||
|
|
5415d0fe3c |
11 changed files with 2159 additions and 167 deletions
|
|
@ -1195,6 +1195,48 @@ class JellyfinClient:
|
|||
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -371,8 +371,13 @@ 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'
|
||||
# Each source stores IDs in its own column
|
||||
if source == 'itunes':
|
||||
track_id_col = 'itunes_track_id'
|
||||
elif source == 'deezer':
|
||||
track_id_col = 'deezer_track_id'
|
||||
else:
|
||||
track_id_col = 'spotify_track_id'
|
||||
|
||||
seasonal_tracks = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
709
web_server.py
709
web_server.py
|
|
@ -2545,6 +2545,8 @@ def _get_file_lock(file_path):
|
|||
# --- Download Missing Tracks Modal State Management ---
|
||||
# Thread-safe state tracking for modal download functionality with batch management
|
||||
missing_download_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="MissingTrackWorker")
|
||||
# Separate executor for analysis to prevent starvation when download workers are busy
|
||||
analysis_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="AnalysisWorker")
|
||||
download_tasks = {} # task_id -> task state dict
|
||||
download_batches = {} # batch_id -> {queue, active_count, max_concurrent}
|
||||
tasks_lock = threading.Lock()
|
||||
|
|
@ -3914,6 +3916,7 @@ def _shutdown_runtime_components():
|
|||
(retag_executor, "retag executor"),
|
||||
(sync_executor, "sync executor"),
|
||||
(missing_download_executor, "missing download executor"),
|
||||
(analysis_executor, "analysis executor"),
|
||||
(tidal_discovery_executor, "tidal discovery executor"),
|
||||
(deezer_discovery_executor, "deezer discovery executor"),
|
||||
(spotify_public_discovery_executor, "spotify public discovery executor"),
|
||||
|
|
@ -24867,7 +24870,7 @@ def _process_wishlist_automatically(automation_id=None):
|
|||
log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success')
|
||||
|
||||
# Submit the wishlist processing job using existing infrastructure
|
||||
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
|
||||
_submit_or_queue_batch(batch_id, playlist_id, wishlist_tracks)
|
||||
|
||||
# Don't mark auto_processing as False here - let completion handler do it
|
||||
|
||||
|
|
@ -26065,7 +26068,7 @@ def start_wishlist_missing_downloads():
|
|||
}
|
||||
|
||||
# Submit the wishlist processing job using the same processing function
|
||||
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
|
||||
_submit_or_queue_batch(batch_id, playlist_id, wishlist_tracks)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
|
|
@ -28864,8 +28867,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):
|
||||
get_database().update_sync_history_push_status(batch_id, 'pending')
|
||||
threading.Thread(
|
||||
target=_push_playlist_to_server,
|
||||
args=(batch_id, batch),
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||||
if playlist_id and playlist_id.startswith('youtube_'):
|
||||
url_hash = playlist_id.replace('youtube_', '')
|
||||
if url_hash in youtube_playlist_states:
|
||||
|
|
@ -28974,6 +28991,43 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
logger.info(f"[Batch Manager] Starting next batch for {batch_id}")
|
||||
_start_next_batch_of_downloads(batch_id)
|
||||
|
||||
|
||||
def _submit_or_queue_batch(batch_id, playlist_id, tracks):
|
||||
"""Submit a batch for analysis, or queue it if 3 analysis slots are full."""
|
||||
with tasks_lock:
|
||||
active_analysis_count = sum(1 for b in download_batches.values()
|
||||
if b.get('phase') == 'analysis')
|
||||
if active_analysis_count >= 3:
|
||||
download_batches[batch_id]['phase'] = 'queued'
|
||||
download_batches[batch_id]['_queued_tracks'] = tracks
|
||||
download_batches[batch_id]['_queued_playlist_id'] = playlist_id
|
||||
name = download_batches[batch_id].get('playlist_name', batch_id)
|
||||
logger.info(f"[Queue] Batch {batch_id} ('{name}') queued — {active_analysis_count} analysis already running")
|
||||
return
|
||||
analysis_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, tracks)
|
||||
|
||||
|
||||
def _promote_queued_batches():
|
||||
"""Check for queued batches and promote them to analysis if capacity allows."""
|
||||
with tasks_lock:
|
||||
active_analysis_count = sum(1 for b in download_batches.values()
|
||||
if b.get('phase') == 'analysis')
|
||||
if active_analysis_count >= 3:
|
||||
return
|
||||
# Find batches waiting in queue, ordered by creation (dict insertion order)
|
||||
for bid, batch in list(download_batches.items()):
|
||||
if batch.get('phase') != 'queued':
|
||||
continue
|
||||
queued_tracks = batch.pop('_queued_tracks', None)
|
||||
queued_pid = batch.pop('_queued_playlist_id', None)
|
||||
if queued_tracks and queued_pid:
|
||||
logger.info(f"[Queue] Promoting batch {bid} ('{batch.get('playlist_name')}') from queued -> analysis")
|
||||
analysis_executor.submit(_run_full_missing_tracks_process, bid, queued_pid, queued_tracks)
|
||||
active_analysis_count += 1
|
||||
if active_analysis_count >= 3:
|
||||
break
|
||||
|
||||
|
||||
def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
|
||||
"""
|
||||
A master worker that handles the entire missing tracks process:
|
||||
|
|
@ -29363,6 +29417,12 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
|
|||
|
||||
download_batches[batch_id]['phase'] = 'downloading'
|
||||
|
||||
# Analysis slot freed — promote any queued batches
|
||||
_promote_queued_batches()
|
||||
|
||||
with tasks_lock:
|
||||
if batch_id not in download_batches: return
|
||||
|
||||
# Store album pre-flight results on batch for source reuse
|
||||
if preflight_source and preflight_tracks:
|
||||
download_batches[batch_id]['last_good_source'] = preflight_source
|
||||
|
|
@ -31088,6 +31148,8 @@ def start_playlist_missing_downloads(playlist_id):
|
|||
'active_count': 0,
|
||||
'max_concurrent': _get_max_concurrent(),
|
||||
'queue_index': 0,
|
||||
'playlist_id': playlist_id,
|
||||
'playlist_name': playlist_name,
|
||||
# Track state management (replicating sync.py)
|
||||
'permanently_failed_tracks': [],
|
||||
'cancelled_tracks': set(),
|
||||
|
|
@ -31510,13 +31572,26 @@ def get_all_downloads_unified():
|
|||
album = str(raw_album) if raw_album else ''
|
||||
|
||||
artwork = track_info.get('artwork_url') or track_info.get('image_url') or track_info.get('album_art') or ''
|
||||
# Try album images
|
||||
# Try album images (both image_url and images[] formats)
|
||||
if not artwork:
|
||||
raw_alb = track_info.get('album')
|
||||
if isinstance(raw_alb, dict):
|
||||
artwork = raw_alb.get('image_url') or ''
|
||||
if not artwork:
|
||||
images = raw_alb.get('images') or []
|
||||
if images and isinstance(images, list) and len(images) > 0:
|
||||
artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
|
||||
# Try spotify_data nested object (wishlist tracks store metadata here)
|
||||
if not artwork:
|
||||
sp_data = track_info.get('spotify_data') or {}
|
||||
if isinstance(sp_data, dict):
|
||||
sp_album = sp_data.get('album') or {}
|
||||
if isinstance(sp_album, dict):
|
||||
artwork = sp_album.get('image_url') or ''
|
||||
if not artwork:
|
||||
sp_imgs = sp_album.get('images') or []
|
||||
if sp_imgs and isinstance(sp_imgs, list) and isinstance(sp_imgs[0], dict):
|
||||
artwork = sp_imgs[0].get('url', '')
|
||||
|
||||
status = task.get('status', 'queued')
|
||||
# Determine download progress percentage
|
||||
|
|
@ -31560,6 +31635,86 @@ def get_all_downloads_unified():
|
|||
# Sort: active first (by priority), then by timestamp desc within each group
|
||||
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
|
||||
|
||||
# POST-LOCK: Enrich missing artwork from metadata cache (handles legacy
|
||||
# wishlist tracks that were stored without image data)
|
||||
items_needing_art = [it for it in items if not it.get('artwork') and it.get('title')]
|
||||
if items_needing_art:
|
||||
try:
|
||||
database = get_database()
|
||||
with database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# 1. Batch lookup by track name
|
||||
track_names = list({it['title'] for it in items_needing_art if it['title']})
|
||||
name_to_art = {}
|
||||
if track_names:
|
||||
placeholders = ','.join('?' for _ in track_names)
|
||||
cursor.execute(f"""
|
||||
SELECT name, image_url FROM metadata_cache_entities
|
||||
WHERE entity_type='track' AND name IN ({placeholders})
|
||||
AND image_url IS NOT NULL AND image_url != ''
|
||||
""", track_names)
|
||||
for row in cursor.fetchall():
|
||||
if row[0] not in name_to_art:
|
||||
name_to_art[row[0]] = row[1]
|
||||
for it in items_needing_art:
|
||||
cached_url = name_to_art.get(it['title'])
|
||||
if cached_url:
|
||||
it['artwork'] = cached_url
|
||||
|
||||
# 2. Remaining items: look up by album name (album art is better than nothing)
|
||||
still_needing = [it for it in items_needing_art if not it.get('artwork') and it.get('album')]
|
||||
if still_needing:
|
||||
album_names = list({it['album'] for it in still_needing if it['album']})
|
||||
if album_names:
|
||||
placeholders = ','.join('?' for _ in album_names)
|
||||
cursor.execute(f"""
|
||||
SELECT name, image_url FROM metadata_cache_entities
|
||||
WHERE entity_type='album' AND name IN ({placeholders})
|
||||
AND image_url IS NOT NULL AND image_url != ''
|
||||
""", album_names)
|
||||
album_to_art = {}
|
||||
for row in cursor.fetchall():
|
||||
if row[0] not in album_to_art:
|
||||
album_to_art[row[0]] = row[1]
|
||||
for it in still_needing:
|
||||
cached_url = album_to_art.get(it['album'])
|
||||
if cached_url:
|
||||
it['artwork'] = cached_url
|
||||
|
||||
# 3. Last resort: check matched_downloads_context for items with active downloads
|
||||
final_needing = [it for it in items_needing_art if not it.get('artwork')]
|
||||
if final_needing:
|
||||
with matched_context_lock:
|
||||
for it in final_needing:
|
||||
# Find task in download_tasks to get username/filename for context lookup
|
||||
task_id = it.get('task_id')
|
||||
if not task_id:
|
||||
continue
|
||||
with tasks_lock:
|
||||
task = download_tasks.get(task_id)
|
||||
if not task:
|
||||
continue
|
||||
t_username = task.get('username')
|
||||
t_filename = task.get('filename')
|
||||
if t_username and t_filename:
|
||||
ctx_key = _make_context_key(t_username, t_filename)
|
||||
ctx = matched_downloads_context.get(ctx_key)
|
||||
if ctx:
|
||||
_sp_album = ctx.get('spotify_album') or {}
|
||||
_sp_track = ctx.get('track_info') or {}
|
||||
_sp_images = _sp_album.get('images') or []
|
||||
_art = ''
|
||||
if _sp_images and isinstance(_sp_images[0], dict):
|
||||
_art = _sp_images[0].get('url', '') or ''
|
||||
if not _art:
|
||||
_art = _sp_album.get('image_url') or ''
|
||||
if not _art:
|
||||
_art = _sp_track.get('image_url') or ''
|
||||
if _art:
|
||||
it['artwork'] = _art
|
||||
except Exception as cache_err:
|
||||
logger.debug(f"Metadata cache artwork lookup failed: {cache_err}")
|
||||
|
||||
# Build batch summaries for the batch context panel
|
||||
batch_summaries = []
|
||||
with tasks_lock:
|
||||
|
|
@ -31573,6 +31728,8 @@ 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)),
|
||||
'analysis_processed': batch.get('analysis_processed', 0),
|
||||
'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')),
|
||||
|
|
@ -31619,9 +31776,16 @@ def clear_completed_downloads():
|
|||
for tid in task_ids_to_remove:
|
||||
del download_tasks[tid]
|
||||
cleared += 1
|
||||
# Also clean up empty batches
|
||||
# Also clean up empty batches (but not those still actively processing)
|
||||
active_phases = {'analysis', 'downloading', 'queued'}
|
||||
empty_batches = []
|
||||
for bid, batch in download_batches.items():
|
||||
# Never remove batches that are still actively working
|
||||
if batch.get('phase') in active_phases:
|
||||
# Still prune cleared tasks from queue
|
||||
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
|
||||
batch['queue'] = remaining
|
||||
continue
|
||||
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
|
||||
if not remaining:
|
||||
empty_batches.append(bid)
|
||||
|
|
@ -32050,8 +32214,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 +32257,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 +32278,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):
|
||||
get_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 +32666,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 +32767,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 +32781,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 +32823,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 ==
|
||||
|
|
@ -33244,16 +33574,6 @@ def start_missing_tracks_process(playlist_id):
|
|||
if playlist_folder_mode:
|
||||
logger.info(f"[Playlist Folder] Enabled for playlist: '{playlist_name}'")
|
||||
|
||||
# Limit concurrent analysis processes to prevent resource exhaustion
|
||||
with tasks_lock:
|
||||
active_analysis_count = sum(1 for batch in download_batches.values()
|
||||
if batch.get('phase') == 'analysis')
|
||||
if active_analysis_count >= 3: # Allow max 3 concurrent analysis processes
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Too many analysis processes running. Please wait for one to complete."
|
||||
}), 429
|
||||
|
||||
batch_id = str(uuid.uuid4())
|
||||
|
||||
with tasks_lock:
|
||||
|
|
@ -33287,6 +33607,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:
|
||||
|
|
@ -33336,7 +33658,7 @@ def start_missing_tracks_process(playlist_id):
|
|||
if '_original_index' not in track:
|
||||
track['_original_index'] = i
|
||||
|
||||
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, tracks)
|
||||
_submit_or_queue_batch(batch_id, playlist_id, tracks)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
|
|
@ -33371,6 +33693,8 @@ def start_missing_downloads():
|
|||
'active_count': 0,
|
||||
'max_concurrent': _get_max_concurrent(),
|
||||
'queue_index': 0,
|
||||
'playlist_id': playlist_id,
|
||||
'playlist_name': 'Legacy Modal',
|
||||
# Track state management (replicating sync.py)
|
||||
'permanently_failed_tracks': [],
|
||||
'cancelled_tracks': set(),
|
||||
|
|
@ -39429,6 +39753,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 +39769,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 +44120,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 +44139,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 = ? AND profile_id = ?", (active_source, pid)
|
||||
).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 +44475,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 +44626,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 +47126,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),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
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,601 @@ 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>';
|
||||
}
|
||||
}
|
||||
|
||||
// Grid container lazy-init — we wrap cards in a .discover-sync-grid div so
|
||||
// the vertical scroll container holds a proper grid. Only one grid per
|
||||
// container; subsequent calls append to the existing grid.
|
||||
function _getOrCreateDiscoverSyncGrid(container) {
|
||||
let grid = container.querySelector(':scope > .discover-sync-grid');
|
||||
if (!grid) {
|
||||
grid = document.createElement('div');
|
||||
grid.className = 'discover-sync-grid';
|
||||
container.appendChild(grid);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
// Deterministic hue per playlist type — same playlist always gets the same
|
||||
// gradient-glow color across reloads. Mirrors the i*37+200 formula used by
|
||||
// server-pl-card for color-wheel spread.
|
||||
function _discoverSyncHue(playlistType) {
|
||||
let hash = 0;
|
||||
const s = String(playlistType || '');
|
||||
for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0;
|
||||
return ((hash % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function renderDiscoverSyncCard(playlist, container, sourceLabel) {
|
||||
const grid = _getOrCreateDiscoverSyncGrid(container);
|
||||
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}`;
|
||||
card.style.setProperty('--card-hue', _discoverSyncHue(playlist.type));
|
||||
|
||||
const lastSyncedText = playlist.last_synced
|
||||
? `Synced ${timeAgo(playlist.last_synced)}`
|
||||
: 'Never synced';
|
||||
|
||||
const statusClass = playlist.sync_status === 'syncing' ? 'syncing' :
|
||||
playlist.sync_status === 'synced' ? 'synced' : 'not-synced';
|
||||
// Text format mirrors the strings the sync-poll code writes back into
|
||||
// this element on transitions (see pollDiscoverBatchFromTab) — keeping
|
||||
// them aligned means the user sees consistent text across the
|
||||
// render → syncing → synced lifecycle.
|
||||
let statusText = playlist.sync_status === 'syncing' ? 'Syncing...' :
|
||||
playlist.sync_status === 'synced' ? 'Synced' : 'Not synced';
|
||||
|
||||
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`;
|
||||
const safePlaylistName = (playlist.name || '').replace(/'/g, "\\'");
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="discover-sync-card-glow"></div>
|
||||
<div class="discover-sync-card-top">
|
||||
<div class="discover-sync-card-icon">${playlist.icon || '🎵'}</div>
|
||||
<div class="discover-sync-source-badge">${sourceLabel || 'unknown'}</div>
|
||||
</div>
|
||||
<div class="discover-sync-card-body">
|
||||
<div class="discover-sync-card-name">${playlist.name}</div>
|
||||
<div class="discover-sync-card-meta">
|
||||
<span class="discover-sync-track-count">${trackLabel}</span>
|
||||
<span class="discover-sync-status ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="discover-sync-last-synced">${lastSyncedText}</div>
|
||||
</div>
|
||||
<div class="discover-sync-card-toggles">
|
||||
<label class="discover-sync-toggle-row" title="${isEmpty ? 'No tracks available — visit Discover first' : 'Keep this playlist updated automatically'}">
|
||||
<span class="discover-sync-toggle-label">Keep updated</span>
|
||||
<span 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>
|
||||
</span>
|
||||
</label>
|
||||
<label class="discover-sync-toggle-row disabled" title="Coming soon: download any available quality for this batch, even if it's below your global quality profile. Useful for rotating discover playlists where quantity matters more than quality.">
|
||||
<span class="discover-sync-toggle-label">Any Quality</span>
|
||||
<span class="discover-sync-toggle">
|
||||
<input type="checkbox" id="discover-any-quality-${playlist.type}" disabled>
|
||||
<span class="discover-sync-toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="discover-sync-card-action" id="discover-sync-btn-${playlist.type}"
|
||||
onclick="event.stopPropagation(); syncDiscoverPlaylistFromTab('${playlist.type}', '${safePlaylistName}')"
|
||||
${playlist.sync_status === 'syncing' || isEmpty ? 'disabled' : ''}>⟳ Sync Now</button>
|
||||
`;
|
||||
|
||||
// Clicking the top (icon + badge) or body (name + meta) opens the track list.
|
||||
// The Sync Now button stops propagation so it doesn't double-trigger.
|
||||
if (!isEmpty) {
|
||||
const openTracks = () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon);
|
||||
card.querySelectorAll('.discover-sync-card-top, .discover-sync-card-body').forEach(el => {
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('click', openTracks);
|
||||
});
|
||||
}
|
||||
|
||||
grid.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.
|
||||
const bodyPayload = {
|
||||
tracks: syncTracks,
|
||||
playlist_name: playlistName
|
||||
};
|
||||
|
||||
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) {
|
||||
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) {
|
||||
// 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()">×</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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,15 @@ 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];
|
||||
}
|
||||
}
|
||||
// Reset so discover tab refetches on next visit
|
||||
discoverSyncPlaylistsLoaded = false;
|
||||
}
|
||||
switch (pageId) {
|
||||
case 'dashboard':
|
||||
|
|
@ -2246,6 +2261,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();
|
||||
|
|
|
|||
|
|
@ -2163,14 +2163,14 @@ let _adlFilterBatchId = null; // When set, main list shows only this batch
|
|||
const _batchColorMap = {};
|
||||
const _batchCompletedAt = {}; // batch_id -> timestamp when first seen as complete
|
||||
let _batchColorNext = 0;
|
||||
const _BATCH_COLOR_COUNT = 16;
|
||||
|
||||
function _getBatchColor(batchId) {
|
||||
if (!batchId) return -1;
|
||||
if (_batchColorMap[batchId] === undefined) {
|
||||
// Deterministic color from batch_id hash for consistency across reloads
|
||||
let hash = 0;
|
||||
for (let i = 0; i < batchId.length; i++) hash = ((hash << 5) - hash + batchId.charCodeAt(i)) | 0;
|
||||
_batchColorMap[batchId] = Math.abs(hash) % 8;
|
||||
// Assign colors sequentially so no duplicates until all 16 are used
|
||||
_batchColorMap[batchId] = _batchColorNext % _BATCH_COLOR_COUNT;
|
||||
_batchColorNext++;
|
||||
}
|
||||
return _batchColorMap[batchId];
|
||||
}
|
||||
|
|
@ -2488,6 +2488,10 @@ function _adlRenderBatchPanel() {
|
|||
const hasFailed = batch.failed > 0;
|
||||
const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error';
|
||||
const isActive = batch.phase === 'downloading' && batch.active > 0;
|
||||
const isAnalyzing = batch.phase === 'analysis';
|
||||
const analysisTotal = batch.analysis_total || 0;
|
||||
const analysisProcessed = batch.analysis_processed || 0;
|
||||
const analysisPct = analysisTotal > 0 ? Math.round((analysisProcessed / analysisTotal) * 100) : 0;
|
||||
|
||||
// Fade progress for completing batches
|
||||
let fadeStyle = '';
|
||||
|
|
@ -2507,14 +2511,22 @@ function _adlRenderBatchPanel() {
|
|||
// Phase label with icon
|
||||
let phaseText = '';
|
||||
let phaseIcon = '';
|
||||
if (batch.phase === 'analysis') {
|
||||
phaseText = 'Analyzing...';
|
||||
if (batch.phase === 'queued') {
|
||||
phaseText = 'Queued';
|
||||
phaseIcon = '<span style="color:#eab308;margin-right:4px">⏳</span>';
|
||||
} else if (batch.phase === 'analysis') {
|
||||
phaseText = analysisTotal > 0 ? `Analyzing ${analysisProcessed}/${analysisTotal}...` : 'Analyzing...';
|
||||
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
|
||||
} else if (batch.phase === 'downloading') {
|
||||
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';
|
||||
|
|
@ -2602,7 +2614,7 @@ function _adlRenderBatchPanel() {
|
|||
</div>
|
||||
</div>
|
||||
<div class="adl-batch-progress">
|
||||
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
|
||||
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${isAnalyzing ? analysisPct : pct}%"></div>
|
||||
</div>
|
||||
<div class="adl-batch-tracks">${tracksHtml}</div>
|
||||
</div>`;
|
||||
|
|
@ -2633,6 +2645,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)) {
|
||||
|
|
@ -2816,10 +2834,21 @@ function _adlRenderBatchHistory() {
|
|||
list.innerHTML = _adlBatchHistory.map(h => {
|
||||
const name = _adlEsc(h.playlist_name || 'Unknown');
|
||||
const downloaded = h.tracks_downloaded || 0;
|
||||
const found = h.tracks_found || 0;
|
||||
const failed = h.tracks_failed || 0;
|
||||
const total = h.total_tracks || 0;
|
||||
const statsParts = [`${downloaded}/${total}`];
|
||||
if (failed > 0) statsParts.push(`<span style="color:#ef4444">${failed} failed</span>`);
|
||||
|
||||
// Build stats line: "X owned · X new · X failed · X missing"
|
||||
const statsParts = [];
|
||||
if (found > 0 || downloaded > 0) {
|
||||
const owned = found - downloaded; // already in library before this sync
|
||||
if (owned > 0) statsParts.push(`<span style="color:rgba(74,222,128,0.7)" title="Already in library">${owned} owned</span>`);
|
||||
if (downloaded > 0) statsParts.push(`<span style="color:rgba(96,165,250,0.7)" title="Newly downloaded">${downloaded} new</span>`);
|
||||
}
|
||||
if (failed > 0) statsParts.push(`<span style="color:#ef4444" title="Failed to download">${failed} failed</span>`);
|
||||
const notFound = total - found - failed;
|
||||
if (notFound > 0) statsParts.push(`<span title="Not found on any source">${notFound} missing</span>`);
|
||||
if (statsParts.length === 0) statsParts.push(`${total} tracks`);
|
||||
|
||||
let dateText = '';
|
||||
if (h.completed_at) {
|
||||
|
|
@ -2836,18 +2865,66 @@ function _adlRenderBatchHistory() {
|
|||
}
|
||||
}
|
||||
|
||||
const sourceLabel = h.source_page ? `<span class="adl-batch-card-source" style="font-size:0.6rem;padding:0 4px">${_adlEsc(h.source_page)}</span>` : '';
|
||||
// Use source (spotify, mirrored, discover, etc.) for badge when available, fall back to source_page
|
||||
const badgeLabel = h.source || h.source_page || '';
|
||||
const sourceLabel = badgeLabel ? `<span class="adl-batch-card-source" style="font-size:0.6rem;padding:0 4px">${_adlEsc(badgeLabel)}</span>` : '';
|
||||
|
||||
// Source type color dot
|
||||
const sourceColors = { wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129' };
|
||||
const dotColor = sourceColors[h.source_page] || '255, 255, 255';
|
||||
const histDot = `<span class="adl-batch-history-dot" style="background:rgba(${dotColor}, 0.6)"></span>`;
|
||||
// Source type color dot - expanded palette
|
||||
const sourceColors = {
|
||||
wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129',
|
||||
discover: '251, 191, 36', mirrored: '236, 72, 153', spotify: '30, 215, 96',
|
||||
youtube: '255, 0, 0', tidal: '0, 255, 255', deezer: '162, 73, 255',
|
||||
beatport: '148, 252, 19', listenbrainz: '255, 134, 0'
|
||||
};
|
||||
const dotSource = h.source || h.source_page || '';
|
||||
const dotColor = sourceColors[dotSource] || '255, 255, 255';
|
||||
const dotTip = dotSource ? `Source: ${dotSource}` : 'Unknown source';
|
||||
const histDot = `<span class="adl-batch-history-dot" style="background:rgba(${dotColor}, 0.6)" title="${dotTip}"></span>`;
|
||||
|
||||
// Thumbnail with playlist-type icon fallback
|
||||
let placeholderIcon = '♫';
|
||||
const pName = (h.playlist_name || '').toLowerCase();
|
||||
if (pName.includes('fresh tape') || pName.includes('release radar')) placeholderIcon = '🎵';
|
||||
else if (pName.includes('archives') || pName.includes('discovery weekly')) placeholderIcon = '📚';
|
||||
else if (pName.includes('seasonal')) placeholderIcon = '🌿';
|
||||
else if (pName.includes('popular picks')) placeholderIcon = '🔥';
|
||||
else if (pName.includes('hidden gems')) placeholderIcon = '💎';
|
||||
else if (pName.includes('discovery shuffle')) placeholderIcon = '🔀';
|
||||
else if (pName.includes('familiar fav')) placeholderIcon = '❤️';
|
||||
else if (pName.includes('jam')) placeholderIcon = '🎸';
|
||||
else if (pName.includes('explor')) placeholderIcon = '🔭';
|
||||
else if (dotSource === 'mirrored' || dotSource === 'youtube') placeholderIcon = '🔗';
|
||||
else if (dotSource === 'wishlist') placeholderIcon = '⭐';
|
||||
else if (dotSource === 'album') placeholderIcon = '💿';
|
||||
const thumb = h.thumb_url
|
||||
? `<img src="${_adlEsc(h.thumb_url)}" class="adl-batch-history-thumb" loading="lazy" data-icon="${placeholderIcon}" onerror="var s=document.createElement('span');s.className='adl-batch-history-thumb adl-batch-history-thumb-placeholder';s.textContent=this.dataset.icon;this.parentNode.replaceChild(s,this)">`
|
||||
: `<span class="adl-batch-history-thumb adl-batch-history-thumb-placeholder">${placeholderIcon}</span>`;
|
||||
|
||||
// Server push status indicator
|
||||
let pushBadge = '';
|
||||
if (h.server_push_status) {
|
||||
const pushIcons = {
|
||||
pending: ['⏳', 'rgba(251,191,36,0.7)', 'Waiting to push to server'],
|
||||
pushing: ['⬆', 'rgba(96,165,250,0.7)', 'Pushing to server...'],
|
||||
success: ['✓', 'rgba(74,222,128,0.7)', 'Pushed to server'],
|
||||
failed: ['✗', 'rgba(239,68,68,0.7)', 'Server push failed'],
|
||||
skipped: ['—', 'rgba(255,255,255,0.2)', 'Server push skipped'],
|
||||
};
|
||||
const [icon, color, tip] = pushIcons[h.server_push_status] || ['?', 'rgba(255,255,255,0.3)', h.server_push_status];
|
||||
pushBadge = ` · <span style="color:${color}" title="${tip}">${icon} server</span>`;
|
||||
}
|
||||
|
||||
return `<div class="adl-batch-history-item">
|
||||
${thumb}
|
||||
<div class="adl-batch-history-content">
|
||||
<div class="adl-batch-history-row1">
|
||||
${histDot}
|
||||
<div class="adl-batch-history-name">${name} ${sourceLabel}</div>
|
||||
<div class="adl-batch-history-stats">${statsParts.join(' ')}</div>
|
||||
<div class="adl-batch-history-date">${dateText}</div>
|
||||
<span class="adl-batch-history-name">${name}</span>
|
||||
${sourceLabel}
|
||||
<span class="adl-batch-history-date">${dateText}</span>
|
||||
</div>
|
||||
<div class="adl-batch-history-row2">${statsParts.join(' · ')}${pushBadge}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -56369,6 +56371,14 @@ body.reduce-effects *::after {
|
|||
--batch-color-5: 239, 68, 68; /* red */
|
||||
--batch-color-6: 6, 182, 212; /* cyan */
|
||||
--batch-color-7: 168, 85, 247; /* purple */
|
||||
--batch-color-8: 234, 179, 8; /* yellow */
|
||||
--batch-color-9: 249, 115, 22; /* orange */
|
||||
--batch-color-10: 34, 197, 94; /* green */
|
||||
--batch-color-11: 99, 102, 241; /* indigo */
|
||||
--batch-color-12: 244, 63, 94; /* rose */
|
||||
--batch-color-13: 20, 184, 166; /* teal */
|
||||
--batch-color-14: 217, 70, 239; /* fuchsia */
|
||||
--batch-color-15: 56, 189, 248; /* sky */
|
||||
}
|
||||
|
||||
.adl-layout {
|
||||
|
|
@ -56799,7 +56809,7 @@ body.reduce-effects *::after {
|
|||
|
||||
/* ---- Batch Context Panel ---- */
|
||||
.adl-batch-panel {
|
||||
width: 340px;
|
||||
width: 400px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 0 12px 0 24px;
|
||||
|
|
@ -57178,29 +57188,66 @@ body.reduce-effects *::after {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.adl-batch-history-name {
|
||||
.adl-batch-history-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.adl-batch-history-thumb-placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.adl-batch-history-thumb-placeholder:empty::after {
|
||||
content: '♫';
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.adl-batch-history-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.adl-batch-history-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.adl-batch-history-name {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.adl-batch-history-stats {
|
||||
.adl-batch-history-row2 {
|
||||
font-size: 0.68rem;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.adl-batch-history-date {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
|
|
@ -59521,3 +59568,400 @@ 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;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────── */
|
||||
/* SoulSync Discover cards — grid layout mirroring server-pl-card pattern */
|
||||
/* ─────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.discover-sync-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.discover-sync-card {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 14px 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.25s ease, border-color 0.25s ease, background 0.25s ease;
|
||||
}
|
||||
|
||||
.discover-sync-card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: hsla(var(--card-hue, 260), 80%, 70%, 0.35);
|
||||
}
|
||||
|
||||
.discover-sync-card-glow {
|
||||
position: absolute;
|
||||
top: -40%;
|
||||
right: -20%;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: radial-gradient(circle,
|
||||
hsla(var(--card-hue, 260), 80%, 60%, 0.22) 0%,
|
||||
hsla(var(--card-hue, 260), 80%, 60%, 0) 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.discover-sync-card:hover .discover-sync-card-glow {
|
||||
opacity: 1.4;
|
||||
}
|
||||
|
||||
.discover-sync-card > *:not(.discover-sync-card-glow) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Top row: icon + source badge ── */
|
||||
.discover-sync-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.discover-sync-card-icon {
|
||||
font-size: 22px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg,
|
||||
hsla(var(--card-hue, 260), 70%, 55%, 0.2),
|
||||
hsla(var(--card-hue, 260), 70%, 40%, 0.1));
|
||||
border: 1px solid hsla(var(--card-hue, 260), 70%, 60%, 0.2);
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discover-sync-source-badge {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
color: hsla(var(--card-hue, 260), 80%, 80%, 1);
|
||||
background: hsla(var(--card-hue, 260), 70%, 55%, 0.12);
|
||||
border: 1px solid hsla(var(--card-hue, 260), 70%, 60%, 0.25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Body: name + meta line + last-synced ── */
|
||||
.discover-sync-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.discover-sync-card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.discover-sync-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.discover-sync-track-count {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.discover-sync-status {
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.discover-sync-status.synced {
|
||||
color: #4ade80;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.discover-sync-status.syncing {
|
||||
color: #fde047;
|
||||
background: rgba(250, 204, 21, 0.14);
|
||||
}
|
||||
|
||||
.discover-sync-status.not-synced {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.discover-sync-last-synced {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Toggles (stacked rows of label + switch) ── */
|
||||
.discover-sync-card-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.discover-sync-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.discover-sync-toggle-row.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.discover-sync-toggle-label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.discover-sync-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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: 18px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.discover-sync-toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.discover-sync-toggle input:checked + .discover-sync-toggle-slider {
|
||||
background: hsla(var(--card-hue, 260), 75%, 60%, 1);
|
||||
}
|
||||
|
||||
.discover-sync-toggle input:checked + .discover-sync-toggle-slider::before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ── Primary action button ── */
|
||||
/* Kept as a flat-text button (no nested children) because the sync-poll
|
||||
code writes `btn.textContent = 'Syncing…'` / `'⟳ Sync Now'` on state
|
||||
transitions — any child elements would be wiped on every toggle. */
|
||||
.discover-sync-card-action {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: linear-gradient(135deg,
|
||||
hsla(var(--card-hue, 260), 70%, 55%, 0.85),
|
||||
hsla(var(--card-hue, 260), 70%, 42%, 0.85));
|
||||
color: #fff;
|
||||
border: 1px solid hsla(var(--card-hue, 260), 70%, 65%, 0.35);
|
||||
border-radius: 10px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.discover-sync-card-action:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 6px 18px hsla(var(--card-hue, 260), 70%, 50%, 0.35);
|
||||
}
|
||||
|
||||
.discover-sync-card-action:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(30%);
|
||||
}
|
||||
|
||||
/* ── Empty / highlight states ── */
|
||||
.discover-sync-card-empty {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.discover-sync-card-empty .discover-sync-toggle-slider {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.discover-sync-card-highlight {
|
||||
animation: discover-card-glow 2.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes discover-card-glow {
|
||||
0% {
|
||||
border-color: hsla(var(--card-hue, 260), 80%, 70%, 0.9);
|
||||
box-shadow: 0 0 24px hsla(var(--card-hue, 260), 80%, 60%, 0.4);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Existing empty-state hint + source info (unchanged) ── */
|
||||
.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-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.discover-sync-card {
|
||||
min-height: 240px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
// 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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue