Add SoulSync Discovery tab to Sync page (Phase 1c.3)
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/<kind>/<variant>/refresh``) + grabs the fresh track snapshot + mirrors under a synthetic id of the form ``ssd_<kind>_<variant>`` (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.
This commit is contained in:
parent
5378b726ee
commit
bd91c94f92
5 changed files with 298 additions and 10 deletions
|
|
@ -1033,6 +1033,9 @@
|
|||
<button class="sync-tab-button" data-tab="lastfm-sync">
|
||||
<span class="tab-icon lastfm-icon"></span> Last.fm
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="soulsync-discovery-sync">
|
||||
<span class="tab-icon soulsync-discovery-icon"></span> SoulSync Discovery
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="import-file">
|
||||
<span class="tab-icon import-file-icon"></span> Import
|
||||
</button>
|
||||
|
|
@ -1918,6 +1921,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SoulSync Discovery Sync Tab Content -->
|
||||
<div class="sync-tab-content" id="soulsync-discovery-sync-tab-content">
|
||||
<div class="playlist-header">
|
||||
<h3>SoulSync Discovery Playlists</h3>
|
||||
<button class="refresh-button soulsync-discovery" id="soulsync-discovery-sync-refresh-btn">🔄 Refresh</button>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="soulsync-discovery-sync-playlist-container">
|
||||
<div class="playlist-placeholder">Click 'Refresh' to load your personalized SoulSync Discovery playlists.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last.fm Radio Sync Tab Content -->
|
||||
<div class="sync-tab-content" id="lastfm-sync-tab-content">
|
||||
<div class="playlist-header">
|
||||
|
|
@ -7997,6 +8011,7 @@
|
|||
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-soulsync-discovery.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -13280,6 +13280,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
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="M10.584 17.21l-.88-2.392s-1.43 1.594-3.573 1.594c-1.897 0-3.244-1.649-3.244-4.288 0-3.382 1.705-4.591 3.381-4.591 2.42 0 3.189 1.567 3.849 3.574l.88 2.749c.88 2.667 2.529 4.81 7.285 4.81 3.409 0 5.717-1.044 5.717-3.793 0-2.227-1.27-3.381-3.629-3.931l-1.762-.385c-1.21-.275-1.567-.77-1.567-1.595 0-.935.742-1.484 1.952-1.484 1.32 0 2.034.495 2.144 1.677l2.749-.33c-.22-2.474-1.926-3.491-4.755-3.491-2.502 0-4.948.935-4.948 3.931 0 1.87.907 3.052 3.189 3.601l1.87.44c1.402.33 1.87 1.622 1.87 1.622-.044 1.21-1.21 1.485-2.667 1.485-2.166 0-3.078-1.142-3.601-2.722l-.907-2.722c-1.16-3.601-3.024-4.921-6.708-4.921C2.193 5.948 0 8.642 0 13.21c0 4.398 2.255 6.781 6.296 6.781 3.244 0 4.811-1.539 4.811-1.539-.082.001-.247-.385-.523-1.242z"/></svg>');
|
||||
}
|
||||
|
||||
.soulsync-discovery-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="%2314b8a6"><path d="M12 1.5l2.7 6.3 6.3.5-4.8 4.2 1.5 6.5L12 15.5l-5.7 3.5 1.5-6.5L3 8.3l6.3-.5z"/></svg>');
|
||||
}
|
||||
|
||||
.sync-tab-button.active .soulsync-discovery-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 1.5l2.7 6.3 6.3.5-4.8 4.2 1.5 6.5L12 15.5l-5.7 3.5 1.5-6.5L3 8.3l6.3-.5z"/></svg>');
|
||||
}
|
||||
|
||||
/* 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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
237
webui/static/sync-soulsync-discovery.js
Normal file
237
webui/static/sync-soulsync-discovery.js
Normal file
|
|
@ -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 = `<div class="playlist-placeholder">🔄 Loading SoulSync Discovery playlists...</div>`;
|
||||
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 = `<div class="playlist-placeholder">❌ ${escapeHtml(data.error || 'Failed to load')}</div>`;
|
||||
return;
|
||||
}
|
||||
_soulsyncDiscoverySyncRecords = data.playlists || [];
|
||||
renderSoulsyncDiscoverySyncPlaylists();
|
||||
console.log(`✨ SoulSync Discovery Sync tab loaded: ${_soulsyncDiscoverySyncRecords.length} playlists`);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${err.message}</div>`;
|
||||
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 = `<div class="playlist-placeholder">No SoulSync Discovery playlists yet. Open the Discover page and generate a few personalized playlists first.</div>`;
|
||||
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 `
|
||||
<div class="youtube-playlist-card soulsync-discovery-playlist-card"
|
||||
id="soulsync-discovery-sync-card-${escapeHtml(syntheticId)}"
|
||||
data-ssd-kind="${escapeHtml(p.kind)}"
|
||||
data-ssd-variant="${escapeHtml(p.variant || '')}"
|
||||
data-ssd-id="${escapeHtml(syntheticId)}"
|
||||
data-ssd-name="${escapeHtml(title)}">
|
||||
<div class="playlist-card-icon">✨</div>
|
||||
<div class="playlist-card-content">
|
||||
<div class="playlist-card-name">${escapeHtml(title)}</div>
|
||||
<div class="playlist-card-info">
|
||||
<span class="playlist-card-track-count">${count} tracks</span>
|
||||
<span class="playlist-card-owner">${escapeHtml(subtitle)}</span>
|
||||
<span class="playlist-card-phase-text" style="color: ${stalenessColor};">${stalenessText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-card-progress hidden"></div>
|
||||
<button class="playlist-card-action-btn">Refresh & Mirror</button>
|
||||
</div>
|
||||
`;
|
||||
}).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);
|
||||
});
|
||||
Loading…
Reference in a new issue