Compare commits

...

30 commits

Author SHA1 Message Date
Broque Thomas
e40eb213b4 Redesign SoulSync Discover cards to grid layout matching Server Playlists
Some checks failed
Compile the app and run tests / sanity-check (push) Has been cancelled
The current cards are full-width horizontal rows, which makes the tab feel
dense once a few playlists accumulate. Rework to a grid of compact cards
that match the Server Playlists visual language already used on the same
Sync page, so the two surfaces feel like the same app.

Changes (all visual — no impact on JohnBaumb's sync/poll logic):

- `.discover-sync-grid` wrapper renders cards as `repeat(auto-fill,
  minmax(240px, 1fr))`. 4-6 cards per row at desktop width.
- Per-card hue derived deterministically from playlist type so each card
  has a consistent color identity across reloads. Drives the icon tint,
  source badge color, toggle color, action-button gradient, and a soft
  radial glow in the top-right corner.
- Top row: 40x40 icon chip + source badge ("DEEZER" / "SPOTIFY") pushed
  to the top-right (mirrors the chevron placement on Server Playlists).
- Body: playlist name (15px bold, 2-line clamp), track count + status
  pill on one line, last-synced subtle line below.
- Compact toggles in a small bordered panel, stacked rows of
  label+switch. Smaller 34x18 switch matches the denser card size.
- Full-width gradient action button ("Sync Now") at the bottom.
- Hover: card lifts 2px, glow + border intensify.

Deliberately kept purely visual to avoid touching JohnBaumb's logic:
- Button content stays flat text ("Sync Now" / "Syncing...") so his
  `btn.textContent = ...` poll updates still work unchanged.
- Status text format matches the strings his sync-poll code writes
  ("Synced 10/15" / "Synced" / "Not synced" / "Syncing..." / "Failed"),
  so initial render and post-sync updates stay consistent.
- All ids and state-managed class hooks preserved: `discover-sync-card-${type}`,
  `discover-sync-btn-${type}`, `.discover-sync-status`, the
  syncing/synced/not-synced modifier classes, and the
  `discover-sync-card-highlight` deep-link animation class.

Staging branch for review — not targeting dev directly. JohnBaumb can
cherry-pick onto his PR branch if he wants the redesign, or skip it.
2026-04-24 11:21:49 -07:00
JohnBaumb
97b0ee960a fix: malformed CSS, seasonal source column, profile_id filter, discover tab stale flag
- style.css: move orphan color declaration inside .discover-sync-card-meta so downstream rules parse correctly
- seasonal_discovery.py: branch on deezer_track_id for deezer source instead of falling through to spotify_track_id
- web_server.py: add AND profile_id = ? to discovery pool count so other profiles don't inflate the result
- init.js: reset discoverSyncPlaylistsLoaded on page leave so the discover tab refetches on revisit
2026-04-24 01:04:50 -07:00
JohnBaumb
150dd046df remove plan file from branch 2026-04-23 13:43:05 -07:00
JohnBaumb
62797c68af discover: replace Force DL toggle with Any Quality placeholder (Phase 0)
Replace the misleading Force DL toggle on the Sync page Discover tab with
a disabled Any Quality toggle. Stop sending force_download_all from
discover sync so discover playlists run ownership analysis instead of
blindly re-downloading owned tracks.

Adds PLAN-download-quality-flags.md describing the full refactor plan.
2026-04-23 13:37:59 -07:00
JohnBaumb
016e8a4c16 fix: server push status NameError + discover icons as thumbnails
- Fix NameError in both completion handlers: 'database' was not defined
  at module scope, replaced with get_database() call
- Add playlist_id and playlist_name to download_missing batch dicts
  so push prefix check works for those code paths
- Replace generic music note placeholder with playlist-type emojis
  in batch history (Fresh Tape=note, Archives=books, etc.)
- Add source-based fallback icons for mirrored/wishlist/album entries
2026-04-23 12:05:37 -07:00
JohnBaumb
038a0189a4 fix: add placeholder thumbnail for history items without images 2026-04-23 11:39:26 -07:00
JohnBaumb
92077c9117 fix: add tooltip to source dot in batch history 2026-04-23 11:35:45 -07:00
JohnBaumb
1f944841a3 fix: add missing closing brace in style.css that broke discover sync CSS 2026-04-23 11:24:20 -07:00
JohnBaumb
a0eb289d63 Merge origin/fix/downloads-page into dev 2026-04-23 11:13:45 -07:00
JohnBaumb
ddae75f0f9 Merge origin/fix/batch-queue-system into dev 2026-04-23 11:13:34 -07:00
JohnBaumb
055c2c25c5 Merge remote-tracking branch 'origin/feat/discover-sync-tab' into dev 2026-04-23 11:13:15 -07:00
JohnBaumb
0fed25b51a Route discover batch card clicks to discover download modal 2026-04-23 11:13:07 -07:00
JohnBaumb
30e3ed621a Support Plex and Jellyfin in playlist server push, add Jellyfin search_tracks 2026-04-23 10:33:50 -07:00
JohnBaumb
c422c30c21 Skip playlist server push for non-navidrome servers instead of failing 2026-04-23 10:30:27 -07:00
JohnBaumb
d7e1cdeb83 Fix seasonal playlist track ID for non-spotify/itunes sources, fix polling interval leak 2026-04-23 10:26:17 -07:00
JohnBaumb
85ea2a810f Merge upstream/dev into feat/discover-sync-tab 2026-04-23 09:50:18 -07:00
JohnBaumb
5706f32ba2 Merge upstream/dev into fix/downloads-page 2026-04-23 09:41:54 -07:00
JohnBaumb
f79cb182d4 Merge upstream/dev into fix/batch-queue-system 2026-04-23 09:33:51 -07:00
JohnBaumb
735a0ebe58 Fix missing album art on downloads page with multi-layer fallback 2026-04-23 01:48:40 -07:00
JohnBaumb
1ef1a4284e Fire-and-forget discover sync with toast link to sync tab 2026-04-23 01:30:43 -07:00
JohnBaumb
f32da04fd1 Add sync tab deep-linking, redirect Discover sync to sync tab, add Force Download toggle 2026-04-23 01:18:34 -07:00
JohnBaumb
d2bd66c0ce Show server push status in batch history 2026-04-23 00:55:46 -07:00
JohnBaumb
ed013d79b9 Track server push status, expand push to all playlist types, rename to _push_playlist_to_server 2026-04-23 00:55:11 -07:00
JohnBaumb
11b74d7c14 Widen batch panel to 400px, shorten stat labels 2026-04-23 00:46:54 -07:00
JohnBaumb
9b3e65c3d8 Two-line batch history layout with descriptive stats 2026-04-23 00:44:59 -07:00
JohnBaumb
332392f338 Improve batch history: thumbnails, source badges, detailed stats 2026-04-23 00:40:33 -07:00
JohnBaumb
c3b9f97afc Add analysis progress to downloads page batch cards 2026-04-22 15:21:11 -07:00
JohnBaumb
3c06e66f03 fix: downloads page album art, total count, and clear-completed safety
- Fix missing album art on downloads page across all sources
- Fix downloads page showing wrong total count
- Enrich missing download artwork from metadata cache
- Prevent Clear Completed from removing active download batches
- Keep download limit at 300 per performance design
2026-04-22 11:59:12 -07:00
JohnBaumb
99a95dcc91 fix: replace 429 rejection with analysis queue and expand batch colors
- Queue batches for analysis instead of rejecting with 429
- Route all batch submissions through analysis queue
- Separate analysis executor to prevent thread starvation
- Expand batch color palette from 8 to 16 sequential colors
- Add queued phase display in batch panel
2026-04-22 11:58:01 -07:00
JohnBaumb
5415d0fe3c feat: add SoulSync Discover sync tab with ListenBrainz, progress tracking, and Navidrome push
Adds a full Discover Sync tab to the Sync page with:
- Core UI scaffolding, playlist modal, empty-state handling
- ListenBrainz playlist integration with auto-update toggle persistence
- Sync progress tracking with matched/total counts on cards
- Navidrome playlist push on batch completion (V1 and V2 paths)
- Active download state display with polling resume on page reload
- Stuck-download detection for downloading and catch-all states
- Serialized sync queue to prevent concurrent backend contention
- Source badges, compact card layout, URL fixes
2026-04-22 11:56:30 -07:00
11 changed files with 2159 additions and 167 deletions

View file

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

View file

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

View file

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

View file

@ -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):
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])
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),
}
})

View file

@ -837,8 +837,7 @@
<div class="sync-header-row">
<div>
<h2 class="sync-title"><img src="/static/sync.png" class="page-header-icon" alt=""><span>Playlist Sync</span></h2>
<p class="sync-subtitle">Synchronize your Spotify, Tidal, and YouTube playlists with your media
server</p>
<p class="sync-subtitle">Sync playlists from Spotify, Tidal, YouTube, Beatport, Deezer, and ListenBrainz to your media server</p>
</div>
<button class="sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
</div>
@ -874,6 +873,9 @@
<button class="sync-tab-button" data-tab="beatport">
<span class="tab-icon beatport-icon"></span> Beatport
</button>
<button class="sync-tab-button" data-tab="discover">
<span class="tab-icon discover-icon"></span> SoulSync Discover
</button>
<button class="sync-tab-button" data-tab="import-file">
<span class="tab-icon import-file-icon"></span> Import
</button>
@ -1747,6 +1749,17 @@
</div>
</div>
<!-- SoulSync Discover Tab Content -->
<div class="sync-tab-content" id="discover-tab-content">
<div class="playlist-header">
<h3>SoulSync Discover</h3>
<p class="discover-sync-subtitle">Playlists generated from your Discover page. Toggle "Keep it updated" to auto-sync when playlists refresh.</p>
</div>
<div class="playlist-scroll-container" id="discover-sync-playlist-container">
<div class="playlist-placeholder">Loading Discover playlists...</div>
</div>
</div>
<!-- Server Playlist Manager Tab -->
<div class="sync-tab-content active" id="server-tab-content">
<div class="playlist-header">

View file

@ -2304,8 +2304,17 @@ function startDecadeSyncPolling(decade, virtualPlaylistId) {
delete _syncProgressCallbacks[virtualPlaylistId];
const syncButton = el(`decade-${decade}-sync-btn`);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
showToast(`${decade}s Classics sync complete!`, 'success');
setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
const _m2 = progress.matched_tracks || matched || 0;
const _t2 = progress.total_tracks || total || 0;
const _miss2 = _t2 - _m2;
if (_miss2 > 0) {
showToast(`${decade}s Classics: ${_m2}/${_t2} matched, ${_miss2} missing`, 'warning');
} else {
showToast(`${decade}s Classics: all ${_t2} tracks matched!`, 'success');
}
if (el(`decade-${decade}-sync-percentage`)) el(`decade-${decade}-sync-percentage`).textContent = '100';
if (el(`decade-${decade}-sync-pending`)) el(`decade-${decade}-sync-pending`).textContent = '0';
setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000);
}
};
}
@ -2347,12 +2356,19 @@ function startDecadeSyncPolling(decade, virtualPlaylistId) {
syncButton.style.cursor = 'pointer';
}
showToast(`${decade}s Classics sync complete!`, 'success');
const missing = total - matched;
if (missing > 0) {
showToast(`${decade}s Classics: ${matched}/${total} matched, ${missing} missing`, 'warning');
} else {
showToast(`${decade}s Classics: all ${total} tracks matched!`, 'success');
}
if (percentageEl) percentageEl.textContent = '100';
if (pendingEl) pendingEl.textContent = '0';
setTimeout(() => {
const statusDisplay = document.getElementById(`decade-${decade}-sync-status`);
if (statusDisplay) statusDisplay.style.display = 'none';
}, 3000);
}, 5000);
}
} catch (error) {
console.error(`Error polling sync status for decade ${decade}:`, error);
@ -2705,8 +2721,17 @@ function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) {
delete _syncProgressCallbacks[virtualPlaylistId];
const syncButton = el(`genre-${genreId}-sync-btn`);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success');
setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
const _m3 = progress.matched_tracks || matched || 0;
const _t3 = progress.total_tracks || total || 0;
const _miss3 = _t3 - _m3;
if (_miss3 > 0) {
showToast(`${capitalizeGenre(genreName)} Mix: ${_m3}/${_t3} matched, ${_miss3} missing`, 'warning');
} else {
showToast(`${capitalizeGenre(genreName)} Mix: all ${_t3} tracks matched!`, 'success');
}
if (el(`genre-${genreId}-sync-percentage`)) el(`genre-${genreId}-sync-percentage`).textContent = '100';
if (el(`genre-${genreId}-sync-pending`)) el(`genre-${genreId}-sync-pending`).textContent = '0';
setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000);
}
};
}
@ -2748,12 +2773,19 @@ function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) {
syncButton.style.cursor = 'pointer';
}
showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success');
const missing = total - matched;
if (missing > 0) {
showToast(`${capitalizeGenre(genreName)} Mix: ${matched}/${total} matched, ${missing} missing`, 'warning');
} else {
showToast(`${capitalizeGenre(genreName)} Mix: all ${total} tracks matched!`, 'success');
}
if (percentageEl) percentageEl.textContent = '100';
if (pendingEl) pendingEl.textContent = '0';
setTimeout(() => {
const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`);
if (statusDisplay) statusDisplay.style.display = 'none';
}, 3000);
}, 5000);
}
} catch (error) {
console.error(`Error polling sync status for genre ${genreName}:`, error);
@ -7817,88 +7849,9 @@ function checkForActiveDiscoverDownloads() {
}
async function startDiscoverPlaylistSync(playlistType, playlistName) {
console.log(`🔄 Starting sync for ${playlistName}`);
console.log(`🔄 Starting sync for ${playlistName} (fire-and-forget from Discover page)`);
// Get tracks based on playlist type
let tracks = [];
if (playlistType === 'release_radar') {
tracks = discoverReleaseRadarTracks;
} else if (playlistType === 'discovery_weekly') {
tracks = discoverWeeklyTracks;
} else if (playlistType === 'seasonal_playlist') {
tracks = discoverSeasonalTracks;
} else if (playlistType === 'popular_picks') {
tracks = personalizedPopularPicks;
} else if (playlistType === 'hidden_gems') {
tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'familiar_favorites') {
tracks = personalizedFamiliarFavorites;
} else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks;
}
if (!tracks || tracks.length === 0) {
showToast(`No tracks available for ${playlistName}`, 'warning');
return;
}
// Convert to format expected by sync API
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
// Use track_data_json if available
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
// Fallback: construct track object
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
// Normalize artists to array of strings for sync compatibility
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create virtual playlist ID
const virtualPlaylistId = `discover_${playlistType}`;
// Store in cache for sync function
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
// Create virtual playlist object
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Add to spotify playlists array if not already there
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
spotifyPlaylists.push(virtualPlaylist);
}
// Show sync status display (convert underscores to hyphens for ID)
const statusId = playlistType.replace(/_/g, '-') + '-sync-status';
const statusDisplay = document.getElementById(statusId);
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
// Disable sync button to prevent duplicate syncs (convert underscores to hyphens for ID)
// Disable the sync button on the Discover page
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
const syncButton = document.getElementById(buttonId);
if (syncButton) {
@ -7907,23 +7860,104 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) {
syncButton.style.cursor = 'not-allowed';
}
// Start sync using existing function
await startPlaylistSync(virtualPlaylistId);
// Extract image URL from first track for download bar bubble
let imageUrl = null;
if (spotifyTracks && spotifyTracks.length > 0) {
const firstTrack = spotifyTracks[0];
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
imageUrl = firstTrack.album.images[0].url;
try {
// Fetch tracks from API
const apiUrl = _discoverPlaylistApiUrl(playlistType);
if (!apiUrl) {
showToast(`Unknown playlist type: ${playlistType}`, 'error');
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
return;
}
const tracksResponse = await fetch(apiUrl);
let tracks = [];
if (tracksResponse.ok) {
const data = await tracksResponse.json();
tracks = data.tracks || [];
}
if (!tracks.length) {
showToast(`No tracks available for ${playlistName}`, 'warning');
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
return;
}
// Convert to sync format
const syncTracks = tracks.map(track => {
if (track.track_data_json) {
const t = track.track_data_json;
if (t.artists && Array.isArray(t.artists)) {
t.artists = t.artists.map(a => a.name || a);
}
return t;
}
return {
id: track.spotify_track_id || track.track_id || '',
name: track.track_name || track.name || '',
artists: [track.artist_name || 'Unknown Artist'],
album: track.album_name || '',
duration_ms: track.duration_ms || 0,
image_url: track.album_cover_url || track.image_url || ''
};
});
const virtualPlaylistId = `discover_${playlistType}`;
// Fire the batch download
const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tracks: syncTracks, playlist_name: playlistName })
});
const result = await batchResponse.json();
if (result.success) {
// Show toast with clickable link to Sync → Discover tab
_showSyncToastWithLink(
`${playlistName} (${syncTracks.length} tracks) syncing...`,
'info',
'View in Sync \u2192',
() => navigateToSyncTab('discover', { highlight: `discover-sync-card-${playlistType}` })
);
} else {
showToast(`Failed to start sync: ${result.error || 'Unknown error'}`, 'error');
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
}
} catch (error) {
console.error(`Error syncing ${playlistName}:`, error);
showToast(`Failed to sync ${playlistName}`, 'error');
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
}
}
// Add to discover download bar
addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl);
/**
* Show a toast with a clickable action link (like showToast but with a custom link).
*/
function _showSyncToastWithLink(message, type, linkText, onClick) {
const container = document.getElementById('toast-container');
if (!container) { showToast(message, type); return; }
// Start polling for progress updates
startDiscoverSyncPolling(playlistType, virtualPlaylistId);
const icon = { success: '\u2705', error: '\u274c', warning: '\u26a0\ufe0f', info: '\u2139\ufe0f' }[type] || '\u2139\ufe0f';
const toast = document.createElement('div');
toast.className = `toast-compact toast-${type}`;
toast.innerHTML = `<span class="toast-compact-icon">${icon}</span><span class="toast-compact-msg">${_escToast(message)}</span>`;
const link = document.createElement('span');
link.className = 'toast-compact-link';
link.textContent = linkText;
link.onclick = e => { e.stopPropagation(); onClick(); };
toast.appendChild(link);
toast.onclick = () => { toast.classList.add('toast-exit'); setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 200); };
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('toast-enter'));
setTimeout(() => {
if (container.contains(toast)) {
toast.classList.add('toast-exit');
setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 300);
}
}, 6000);
}
// Track active discover sync pollers
@ -7967,8 +8001,18 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) {
'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle',
'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist'
};
showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success');
setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
const dn = playlistNames[playlistType] || playlistType;
const _m = progress.matched_tracks || matched || 0;
const _t = progress.total_tracks || total || 0;
const _miss = _t - _m;
if (_miss > 0) {
showToast(`${dn}: ${_m}/${_t} matched, ${_miss} missing`, 'warning');
} else {
showToast(`${dn}: all ${_t} tracks matched!`, 'success');
}
if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = '100';
if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = '0';
setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000);
}
};
}
@ -8035,15 +8079,22 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) {
'build_playlist': 'Custom Playlist'
};
const displayName = playlistNames[playlistType] || playlistType;
showToast(`${displayName} sync complete!`, 'success');
const missing = total - matched;
if (missing > 0) {
showToast(`${displayName}: ${matched}/${total} matched, ${missing} missing`, 'warning');
} else {
showToast(`${displayName}: all ${total} tracks matched!`, 'success');
}
// Hide status display after 3 seconds
// Update status display to show final result, then hide after 5s
if (percentageEl) percentageEl.textContent = '100';
if (pendingEl) pendingEl.textContent = '0';
setTimeout(() => {
const statusDisplay = document.getElementById(`${prefix}-sync-status`);
if (statusDisplay) {
statusDisplay.style.display = 'none';
}
}, 3000);
}, 5000);
}
} catch (error) {
@ -8893,3 +8944,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()">&times;</span>
</div>
<div class="mirrored-modal-tracks">
<div class="mirrored-track-header">
<span>#</span><span></span><span>Track</span><span>Artist</span><span>Album</span><span style="text-align:right">Time</span>
</div>
${trackRows}
</div>
<div class="mirrored-modal-footer">
<div class="mirrored-modal-footer-left"></div>
<div class="mirrored-modal-footer-right" style="display:flex;gap:10px;">
<button class="mirrored-btn-close" onclick="closeDiscoverPlaylistModal()">Close</button>
<button class="mirrored-btn-discover" onclick="closeDiscoverPlaylistModal(); syncDiscoverPlaylistFromTab('${_escAttr(playlistType)}', '${_escAttr(playlistName)}')">Sync Now</button>
</div>
</div>
</div>
`;
overlay.addEventListener('click', e => { if (e.target === overlay) closeDiscoverPlaylistModal(); });
document.body.appendChild(overlay);
} catch (err) {
hideLoadingOverlay();
showToast(`Error loading ${playlistName}: ${err.message}`, 'error');
}
}
function closeDiscoverPlaylistModal() {
const m = document.getElementById('discover-playlist-modal');
if (m) m.remove();
}

View file

@ -2152,7 +2152,13 @@ function navigateToPage(pageId, options = {}) {
// Artists page, now replaced by clicking artists from the unified Search.
if (pageId === 'downloads' || pageId === 'artists') pageId = 'search';
if (pageId === currentPage) return;
if (pageId === currentPage) {
// Already on this page — still process pending sync tab actions
if (pageId === 'sync' && window._pendingSyncTabAction && typeof _applySyncTabAction === 'function') {
_applySyncTabAction();
}
return;
}
// Permission guard — redirect to home page if not allowed
if (!isPageAllowed(pageId)) {
@ -2237,6 +2243,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();

View file

@ -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">
${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>
${thumb}
<div class="adl-batch-history-content">
<div class="adl-batch-history-row1">
${histDot}
<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('');
}

View file

@ -2157,6 +2157,7 @@ function importFileSubmit() {
// ── Mirrored Playlists ────────────────────────────────────────────────
let mirroredPlaylistsLoaded = false;
let discoverSyncPlaylistsLoaded = false;
/**
* Fire-and-forget helper: send parsed playlist data to be mirrored on the backend.

View file

@ -2490,7 +2490,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.helper-first-launch-tip {
position: fixed;
bottom: 34px;
right: 84px;
right: 136px;
padding: 8px 16px;
background: rgba(16, 16, 16, 0.95);
border: 1px solid rgba(var(--accent-rgb), 0.3);
@ -11920,6 +11920,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
overflow: hidden;
border-top-left-radius: 20px;
border-top-right-radius: 20px;
flex-shrink: 0;
}
.mirrored-modal-hero {
@ -12120,6 +12121,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
flex-shrink: 0;
}
.mirrored-modal-footer button {
@ -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;
}

View file

@ -2736,6 +2736,54 @@ async function startDeezerDownloadMissing(urlHash) {
// SYNC PAGE FUNCTIONALITY (REDESIGNED)
// ===============================
/**
* Navigate to the Sync page and activate a specific tab.
* Works from any page. If already on the sync page, just switches the tab.
* @param {string} tabId - Tab data-tab value (e.g. 'discover', 'spotify', 'mirrored')
* @param {object} [opts] - Options
* @param {string} [opts.highlight] - Element ID to scroll to and briefly highlight
* @param {string} [opts.autoSync] - Discover playlist type to auto-trigger sync on
* @param {boolean} [opts.forceDownload] - Pass force_download_all when auto-syncing
*/
function navigateToSyncTab(tabId, opts) {
window._pendingSyncTabAction = { tabId, ...(opts || {}) };
if (typeof currentPage !== 'undefined' && currentPage === 'sync') {
_applySyncTabAction();
} else {
navigateToPage('sync');
}
}
function _applySyncTabAction() {
const action = window._pendingSyncTabAction;
if (!action) return;
window._pendingSyncTabAction = null;
const tabId = action.tabId;
// Click the target tab button to trigger normal tab-switch logic
const btn = document.querySelector(`.sync-tab-button[data-tab="${tabId}"]`);
if (btn && !btn.classList.contains('active')) {
btn.click();
}
// Wait for lazy-loaded content, then highlight / auto-sync
const apply = () => {
if (action.highlight) {
const el = document.getElementById(action.highlight);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('discover-sync-card-highlight');
setTimeout(() => el.classList.remove('discover-sync-card-highlight'), 2500);
}
}
if (action.autoSync) {
syncDiscoverPlaylistFromTab(action.autoSync, action.autoSyncName || action.autoSync);
}
};
// 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;