From bd91c94f92cb51be7007ed99215a4557f4a3286a Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 19:46:03 -0700 Subject: [PATCH] Add SoulSync Discovery tab to Sync page (Phase 1c.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last of the three unified-tab phases. Surfaces the user's persisted personalized playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page so they participate in the mirrored-playlist + Auto-Sync pipeline like every other source. Different shape from the LB / Last.fm tabs: - Tracks already carry Spotify / iTunes / Deezer IDs (matched at generation time from the discovery pool), so there is NO MB-style "needs discovery" hop. The mirror is created with fully-populated ``matched_data`` JSON inline, downstream consumers (sync, wishlist) see canonical extra_data immediately. - Click on a card runs the kind's generator (``POST /api/personalized/playlist///refresh``) + grabs the fresh track snapshot + mirrors under a synthetic id of the form ``ssd__`` (e.g. ``ssd_decade_1980s``, ``ssd_hidden_gems``). Re-clicks UPSERT the same row, so the Auto-Sync schedule survives every refresh. - Sub-tabs / archive concept don't apply here β€” each personalized playlist is already a singleton per (profile, kind, variant); the manager handles its own rotation. New file: ``webui/static/sync-soulsync-discovery.js`` (~210 lines). ``initializeSyncPage`` learns a new tab branch. CSS adds ``soulsync-discovery-icon`` (star SVG, teal ``#14b8a6``) + ``.soulsync-discovery-playlist-card`` joins the unified card selector group with a matching teal accent. WHATS_NEW entry added under 2.6.3. 236 tests still green; no Python paths touched. --- webui/index.html | 15 ++ webui/static/helper.js | 1 + webui/static/style.css | 44 ++++- webui/static/sync-services.js | 11 ++ webui/static/sync-soulsync-discovery.js | 237 ++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 10 deletions(-) create mode 100644 webui/static/sync-soulsync-discovery.js diff --git a/webui/index.html b/webui/index.html index f4fd8a43..8d70b9e7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1033,6 +1033,9 @@ + @@ -1918,6 +1921,17 @@ + +
+
+

SoulSync Discovery Playlists

+ +
+
+
Click 'Refresh' to load your personalized SoulSync Discovery playlists.
+
+
+
@@ -7997,6 +8011,7 @@ + diff --git a/webui/static/helper.js b/webui/static/helper.js index b6a31d44..bc1bd645 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3420,6 +3420,7 @@ const WHATS_NEW = { { title: 'Discovery folded into the unified source contract', desc: 'next slice of the groundwork. each playlist source can now answer one extra question β€” "match these raw tracks against Spotify / iTunes" β€” through the same adapter interface. Spotify / Tidal / Qobuz / YouTube / Deezer / Spotify-public / iTunes-link / SoulSync-Discovery all answer trivially (their tracks already have provider IDs); ListenBrainz + Last.fm run the matching engine. mirror-refresh now calls this automatically when a source returns MB-metadata-only tracks, so when ListenBrainz becomes a Sync-page tab next commit, its mirrors land already discovered + ready to sync β€” no separate Discover-page round-trip needed.' }, { title: 'ListenBrainz Sync tab', desc: 'new ListenBrainz tab on the Sync page, between Beatport and Import. lists your "For You" / "My Playlists" / "Collaborative" LB playlists in one place. clicking a card kicks off the same discovery β†’ sync β†’ mirror flow you already get from the Discover page (no duplicate UI behind the scenes, just a new entry point). once mirrored, LB playlists participate in Auto-Sync schedules + pipeline automations like any other source. needs ListenBrainz connected in Settings β†’ Connections.', page: 'sync' }, { title: 'Last.fm Radio Sync tab', desc: 'sibling to the ListenBrainz tab β€” lists your generated Last.fm Radio playlists alongside the rest of the Sync sources. same discovery β†’ mirror flow under the hood, just a different entry point. new Last.fm radios are still generated from the Discover page by picking a seed track; this tab is for syncing existing ones. mirrors auto-trim when Last.fm Radio cache rotates so old radios don\'t pile up.', page: 'sync' }, + { title: 'SoulSync Discovery Sync tab', desc: 'last of the unified-tab trio. surfaces your personalized SoulSync Discovery playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page. clicking a card regenerates the playlist + mirrors it under a stable synthetic id, so the same mirror updates in place every Auto-Sync refresh. tracks come out already matched against Spotify / iTunes / Deezer so there\'s no discovery hop β€” straight to download / sync.', page: 'sync' }, ], '2.6.2': [ { date: 'May 24, 2026 β€” 2.6.2 release' }, diff --git a/webui/static/style.css b/webui/static/style.css index 71b9ed5d..4c60f887 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -13280,6 +13280,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { background-image: url('data:image/svg+xml;charset=utf-8,'); } +.soulsync-discovery-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.sync-tab-button.active .soulsync-discovery-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + /* ListenBrainz Sync tab sub-tabs (For You / My Playlists / Collaborative). * Neutral dark surface; orange used only as a subtle accent on the * active state β€” matches the rest of the app's tone. */ @@ -17545,7 +17553,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card, .spotify-public-card, .listenbrainz-playlist-card, -.lastfm-playlist-card { +.lastfm-playlist-card, +.soulsync-discovery-playlist-card { background: rgba(18, 18, 22, 0.9); backdrop-filter: blur(12px); border-radius: 16px; @@ -17568,7 +17577,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card::before, .spotify-public-card::before, .listenbrainz-playlist-card::before, -.lastfm-playlist-card::before { +.lastfm-playlist-card::before, +.soulsync-discovery-playlist-card::before { content: ''; position: absolute; top: 0; @@ -17585,6 +17595,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card::before { background: linear-gradient(90deg, transparent, rgba(29, 185, 84, 0.4), transparent); } .listenbrainz-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(235, 116, 59, 0.4), transparent); } .lastfm-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(213, 16, 7, 0.4), transparent); } +.soulsync-discovery-playlist-card::before { background: linear-gradient(90deg, transparent, rgba(20, 184, 166, 0.4), transparent); } /* Hover β€” brand glow */ .youtube-playlist-card:hover { border-color: rgba(255, 0, 0, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(255, 0, 0, 0.06); transform: translateY(-2px); } @@ -17593,6 +17604,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card:hover { border-color: rgba(29, 185, 84, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(29, 185, 84, 0.06); transform: translateY(-2px); } .listenbrainz-playlist-card:hover { border-color: rgba(235, 116, 59, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(235, 116, 59, 0.06); transform: translateY(-2px); } .lastfm-playlist-card:hover { border-color: rgba(213, 16, 7, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(213, 16, 7, 0.06); transform: translateY(-2px); } +.soulsync-discovery-playlist-card:hover { border-color: rgba(20, 184, 166, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(20, 184, 166, 0.06); transform: translateY(-2px); } .youtube-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(255, 0, 0, 0.7), transparent); } .tidal-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(255, 102, 0, 0.7), transparent); } @@ -17600,6 +17612,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(29, 185, 84, 0.7), transparent); } .listenbrainz-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(235, 116, 59, 0.7), transparent); } .lastfm-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(213, 16, 7, 0.7), transparent); } +.soulsync-discovery-playlist-card:hover::before { left: 10%; right: 10%; background: linear-gradient(90deg, transparent, rgba(20, 184, 166, 0.7), transparent); } /* Source icons */ .youtube-playlist-card .playlist-card-icon, @@ -17607,7 +17620,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card .playlist-card-icon, .spotify-public-card .playlist-card-icon, .listenbrainz-playlist-card .playlist-card-icon, -.lastfm-playlist-card .playlist-card-icon { +.lastfm-playlist-card .playlist-card-icon, +.soulsync-discovery-playlist-card .playlist-card-icon { width: 40px; height: 40px; border-radius: 10px; @@ -17627,27 +17641,31 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card .playlist-card-icon { background: rgba(29, 185, 84, 0.12); border: 1px solid rgba(29, 185, 84, 0.2); color: #1DB954; } .listenbrainz-playlist-card .playlist-card-icon { background: rgba(235, 116, 59, 0.12); border: 1px solid rgba(235, 116, 59, 0.2); color: #eb743b; } .lastfm-playlist-card .playlist-card-icon { background: rgba(213, 16, 7, 0.12); border: 1px solid rgba(213, 16, 7, 0.2); color: #d51007; } +.soulsync-discovery-playlist-card .playlist-card-icon { background: rgba(20, 184, 166, 0.12); border: 1px solid rgba(20, 184, 166, 0.2); color: #14b8a6; } .youtube-playlist-card .playlist-card-content, .tidal-playlist-card .playlist-card-content, .deezer-playlist-card .playlist-card-content, .spotify-public-card .playlist-card-content, .listenbrainz-playlist-card .playlist-card-content, -.lastfm-playlist-card .playlist-card-content { flex: 1; min-width: 0; } +.lastfm-playlist-card .playlist-card-content, +.soulsync-discovery-playlist-card .playlist-card-content { flex: 1; min-width: 0; } .youtube-playlist-card .playlist-card-name, .tidal-playlist-card .playlist-card-name, .deezer-playlist-card .playlist-card-name, .spotify-public-card .playlist-card-name, .listenbrainz-playlist-card .playlist-card-name, -.lastfm-playlist-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; } +.lastfm-playlist-card .playlist-card-name, +.soulsync-discovery-playlist-card .playlist-card-name { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin-bottom: 4px; } .youtube-playlist-card .playlist-card-info, .tidal-playlist-card .playlist-card-info, .deezer-playlist-card .playlist-card-info, .spotify-public-card .playlist-card-info, .listenbrainz-playlist-card .playlist-card-info, -.lastfm-playlist-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; } +.lastfm-playlist-card .playlist-card-info, +.soulsync-discovery-playlist-card .playlist-card-info { font-size: 13px; color: rgba(255, 255, 255, 0.4); display: flex; align-items: center; gap: 10px; } .youtube-playlist-card .playlist-card-track-count { color: rgba(255, 255, 255, 0.7); } .youtube-playlist-card .playlist-card-phase-text { font-weight: 500; } @@ -17659,7 +17677,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card .playlist-card-action-btn, .spotify-public-card .playlist-card-action-btn, .listenbrainz-playlist-card .playlist-card-action-btn, -.lastfm-playlist-card .playlist-card-action-btn { +.lastfm-playlist-card .playlist-card-action-btn, +.soulsync-discovery-playlist-card .playlist-card-action-btn { background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); border: 1px solid rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); @@ -17680,7 +17699,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .deezer-playlist-card .playlist-card-action-btn::before, .spotify-public-card .playlist-card-action-btn::before, .listenbrainz-playlist-card .playlist-card-action-btn::before, -.lastfm-playlist-card .playlist-card-action-btn::before { +.lastfm-playlist-card .playlist-card-action-btn::before, +.soulsync-discovery-playlist-card .playlist-card-action-btn::before { content: ''; position: absolute; inset: 0; @@ -17695,13 +17715,15 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(29, 185, 84, 0.2), rgba(29, 185, 84, 0.05)); } .listenbrainz-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(235, 116, 59, 0.2), rgba(235, 116, 59, 0.05)); } .lastfm-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(213, 16, 7, 0.2), rgba(213, 16, 7, 0.05)); } +.soulsync-discovery-playlist-card .playlist-card-action-btn::before { background: linear-gradient(135deg, rgba(20, 184, 166, 0.2), rgba(20, 184, 166, 0.05)); } .youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .deezer-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, .spotify-public-card .playlist-card-action-btn:hover:not(:disabled)::before, .listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, -.lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; } +.lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before, +.soulsync-discovery-playlist-card .playlist-card-action-btn:hover:not(:disabled)::before { opacity: 1; } .youtube-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 0, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 0, 0, 0.15); } .tidal-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(255, 102, 0, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(255, 102, 0, 0.15); } @@ -17709,13 +17731,15 @@ body.helper-mode-active #dashboard-activity-feed:hover { .spotify-public-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(29, 185, 84, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(29, 185, 84, 0.15); } .listenbrainz-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(235, 116, 59, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(235, 116, 59, 0.15); } .lastfm-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(213, 16, 7, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(213, 16, 7, 0.15); } +.soulsync-discovery-playlist-card .playlist-card-action-btn:hover:not(:disabled) { color: #fff; border-color: rgba(20, 184, 166, 0.35); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(20, 184, 166, 0.15); } .youtube-playlist-card .playlist-card-action-btn:disabled, .tidal-playlist-card .playlist-card-action-btn:disabled, .deezer-playlist-card .playlist-card-action-btn:disabled, .spotify-public-card .playlist-card-action-btn:disabled, .listenbrainz-playlist-card .playlist-card-action-btn:disabled, -.lastfm-playlist-card .playlist-card-action-btn:disabled { +.lastfm-playlist-card .playlist-card-action-btn:disabled, +.soulsync-discovery-playlist-card .playlist-card-action-btn:disabled { background: rgba(255, 255, 255, 0.03); border-color: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index c317aba1..63f18be3 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -3764,6 +3764,17 @@ function initializeSyncPage() { _startLbSyncCardRefreshLoop(); } } + + // SoulSync Discovery Sync tab β€” personalized_playlists pre- + // matched, no discovery hop needed; click β†’ refresh kind β†’ + // mirror under synthetic id. + if (tabId === 'soulsync-discovery-sync') { + if (typeof loadSoulsyncDiscoverySyncPlaylists === 'function' + && !window._soulsyncDiscoverySyncTabLoaded) { + window._soulsyncDiscoverySyncTabLoaded = true; + loadSoulsyncDiscoverySyncPlaylists(); + } + } }); }); diff --git a/webui/static/sync-soulsync-discovery.js b/webui/static/sync-soulsync-discovery.js new file mode 100644 index 00000000..f806acbb --- /dev/null +++ b/webui/static/sync-soulsync-discovery.js @@ -0,0 +1,237 @@ +// =================================================================== +// SOULSYNC DISCOVERY SYNC TAB (Phase 1c.3) +// =================================================================== +// Surfaces the user's persisted SoulSync Discovery / personalized +// playlists (decade mixes, hidden gems, popular picks, daily mixes, +// discovery shuffle, etc.) as a Sync-page tab so they participate +// in the mirrored-playlist + Auto-Sync pipeline like every other +// source. +// +// Different shape from the LB / Last.fm tabs: personalized tracks +// already carry Spotify / iTunes / Deezer IDs (matched at generation +// time from the discovery pool), so there's no MB-style "needs +// discovery" hop. Click β†’ refresh kind β†’ grab tracks β†’ mirror as +// ``source='soulsync_discovery'`` with the matched_data shape +// downstream consumers already expect from auto-discovered Spotify +// mirrors. + +let _soulsyncDiscoverySyncRecords = []; + +async function loadSoulsyncDiscoverySyncPlaylists() { + const container = document.getElementById('soulsync-discovery-sync-playlist-container'); + const refreshBtn = document.getElementById('soulsync-discovery-sync-refresh-btn'); + if (!container) return; + + container.innerHTML = `
πŸ”„ Loading SoulSync Discovery playlists...
`; + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = 'πŸ”„ Loading...'; + } + + try { + const resp = await fetch('/api/personalized/playlists'); + const data = await resp.json(); + if (!data.success) { + container.innerHTML = `
❌ ${escapeHtml(data.error || 'Failed to load')}
`; + return; + } + _soulsyncDiscoverySyncRecords = data.playlists || []; + renderSoulsyncDiscoverySyncPlaylists(); + console.log(`✨ SoulSync Discovery Sync tab loaded: ${_soulsyncDiscoverySyncRecords.length} playlists`); + } catch (err) { + container.innerHTML = `
❌ Error: ${err.message}
`; + if (typeof showToast === 'function') { + showToast(`Error loading SoulSync Discovery: ${err.message}`, 'error'); + } + } finally { + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = 'πŸ”„ Refresh'; + } + } +} + +function renderSoulsyncDiscoverySyncPlaylists() { + const container = document.getElementById('soulsync-discovery-sync-playlist-container'); + if (!container) return; + + if (_soulsyncDiscoverySyncRecords.length === 0) { + container.innerHTML = `
No SoulSync Discovery playlists yet. Open the Discover page and generate a few personalized playlists first.
`; + return; + } + + container.innerHTML = _soulsyncDiscoverySyncRecords.map(p => { + const syntheticId = _soulsyncSyntheticId(p.kind, p.variant); + const title = p.name || `${p.kind} ${p.variant || ''}`.trim(); + const subtitle = p.variant ? `${p.kind} Β· ${p.variant}` : p.kind; + const count = p.track_count || 0; + const stale = !!p.is_stale; + const stalenessText = stale ? 'Stale β€” refresh to regenerate' : 'Ready'; + const stalenessColor = stale ? '#facc15' : '#14b8a6'; + + return ` +
+
✨
+
+
${escapeHtml(title)}
+
+ ${count} tracks + ${escapeHtml(subtitle)} + ${stalenessText} +
+
+ + +
+ `; + }).join(''); + + container.querySelectorAll('.soulsync-discovery-playlist-card').forEach(card => { + card.addEventListener('click', () => { + const kind = card.dataset.ssdKind; + const variant = card.dataset.ssdVariant; + const name = card.dataset.ssdName; + handleSoulsyncDiscoverySyncCardClick(kind, variant, name, card); + }); + }); +} + +function _soulsyncSyntheticId(kind, variant) { + // Synthetic stable id keyed on (kind, variant) so re-refreshes UPSERT + // the same mirror row instead of duplicating. Empty variant collapses + // cleanly (e.g. hidden_gems with no variant -> "ssd_hidden_gems"). + return `ssd_${kind}${variant ? `_${variant}` : ''}`; +} + +async function handleSoulsyncDiscoverySyncCardClick(kind, variant, name, cardEl) { + if (!kind) { + if (typeof showToast === 'function') showToast('Missing kind', 'error'); + return; + } + const btn = cardEl ? cardEl.querySelector('.playlist-card-action-btn') : null; + const progEl = cardEl ? cardEl.querySelector('.playlist-card-progress') : null; + if (btn) { + btn.disabled = true; + btn.textContent = 'Refreshing…'; + } + if (progEl) progEl.classList.remove('hidden'); + + try { + // Trigger the kind's generator and grab fresh tracks. + const url = variant + ? `/api/personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}/refresh` + : `/api/personalized/playlist/${encodeURIComponent(kind)}/refresh`; + const resp = await fetch(url, { method: 'POST' }); + const data = await resp.json(); + if (!data.success) { + throw new Error(data.error || 'Generator failed'); + } + + const rec = data.playlist || {}; + const tracks = data.tracks || []; + const finalName = rec.name || name || `${kind} ${variant || ''}`.trim(); + const syntheticId = _soulsyncSyntheticId(kind, variant); + + if (tracks.length === 0) { + if (typeof showToast === 'function') { + showToast(`'${finalName}' generated 0 tracks. Try widening the playlist's config in Discover.`, 'warning'); + } + } + + // Project each track into the mirrorPlaylist contract. Tracks + // already carry provider IDs from the discovery pool, so the + // matched_data block is filled inline β€” no separate discovery + // worker pass needed. + const mirrorTracks = tracks.map(t => { + const trackId = t.spotify_track_id || t.itunes_track_id || t.deezer_track_id || ''; + const provider = t.spotify_track_id ? 'spotify' + : (t.itunes_track_id ? 'itunes' + : (t.deezer_track_id ? 'deezer' : (t.source || 'unknown'))); + const albumObj = { name: t.album_name || '' }; + if (t.album_cover_url) { + albumObj.images = [{ url: t.album_cover_url, height: 600, width: 600 }]; + } + const extra = trackId ? JSON.stringify({ + discovered: true, + provider, + confidence: 1.0, + matched_data: { + id: trackId, + name: t.track_name || '', + artists: [{ name: t.artist_name || '' }], + album: albumObj, + duration_ms: t.duration_ms || 0, + image_url: t.album_cover_url || null, + source: provider, + }, + }) : null; + return { + track_name: t.track_name || '', + artist_name: t.artist_name || '', + album_name: t.album_name || '', + duration_ms: t.duration_ms || 0, + image_url: t.album_cover_url || null, + source_track_id: trackId, + extra_data: extra, + }; + }); + + if (typeof mirrorPlaylist === 'function') { + mirrorPlaylist( + 'soulsync_discovery', + syntheticId, + finalName, + mirrorTracks, + { + owner: 'SoulSync', + description: `Personalized ${kind}${variant ? ' Β· ' + variant : ''} β€” regenerates on Auto-Sync refresh.`, + image_url: '', + }, + ); + } + + if (progEl) { + progEl.textContent = `β™ͺ ${tracks.length} / βœ“ ${mirrorTracks.length} / mirrored`; + } + if (btn) { + btn.disabled = false; + btn.textContent = 'Refresh & Mirror'; + } + + // Update the in-memory record so the card displays the new count. + const idx = _soulsyncDiscoverySyncRecords.findIndex( + r => r.kind === kind && (r.variant || '') === (variant || '') + ); + if (idx >= 0) { + _soulsyncDiscoverySyncRecords[idx] = { + ..._soulsyncDiscoverySyncRecords[idx], + ...rec, + track_count: tracks.length, + is_stale: false, + }; + } + + if (typeof showToast === 'function') { + showToast(`Mirrored '${finalName}' with ${mirrorTracks.length} tracks`, 'success'); + } + } catch (err) { + if (btn) { + btn.disabled = false; + btn.textContent = 'Refresh & Mirror'; + } + if (typeof showToast === 'function') { + showToast(`Refresh failed: ${err.message}`, 'error'); + } + console.error('SoulSync Discovery refresh failed:', err); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('soulsync-discovery-sync-refresh-btn'); + if (btn) btn.addEventListener('click', loadSoulsyncDiscoverySyncPlaylists); +});