From a7053a6061ce66ef79656a7ea3c25ee897670098 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 26 May 2026 14:17:44 -0700 Subject: [PATCH] Add ListenBrainz tab to Sync page (Phase 1c.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First user-facing slice of the Discover-to-Sync unification. Adds a ListenBrainz tab on the Sync page alongside Tidal / Qobuz / Spotify Public / Beatport / etc. so users can mirror + auto-sync ListenBrainz playlists from the same surface as every other source, without detouring through the Discover page. The Discover-page LB flow already owns all the heavy lifting (state machine, discovery polling, sync β†’ mirror creation). This commit adds the Sync-page entry point only β€” list cached LB playlists, render cards, pre-fetch tracks on click, hand off to ``openDownloadModalForListenBrainzPlaylist``. Zero backend changes. - ``webui/index.html``: new `` + @@ -1912,6 +1915,22 @@ + +
+
+

Your ListenBrainz Playlists

+
+ + + +
+ +
+
+
Click 'Refresh' to load your ListenBrainz playlists.
+
+
+
@@ -7962,6 +7981,7 @@ + diff --git a/webui/static/helper.js b/webui/static/helper.js index aa59a98d..9c765e1b 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3418,6 +3418,7 @@ const WHATS_NEW = { { title: 'Groundwork: unified playlist source layer', desc: 'first slice of a refactor that\'ll let ListenBrainz, Last.fm radio, and SoulSync Discovery playlists live as Sync-page tabs alongside Spotify / Tidal / Qobuz / YouTube β€” so they can be mirrored + scheduled like the rest. this commit adds the shared adapter layer all those sources will plug into; no UI changes yet. nothing to do on your end.' }, { title: 'Auto-Sync refresh now routes through the unified source layer', desc: 'follow-up to the groundwork above. the mirrored-playlist auto-refresh handler used to have a ~190-line if/elif chain branching per source (one branch each for Spotify, Spotify public, Deezer, Tidal, YouTube). now it asks the source registry for the right adapter and calls one refresh method. behavior identical β€” same matched_data, same Tidal-skip-on-no-auth log, same Spotify-public-prefers-authed-API fallback. unlocks ListenBrainz / Last.fm / SoulSync Discovery as future Sync-page mirror sources without a fresh elif branch each time.' }, { 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' }, ], '2.6.2': [ { date: 'May 24, 2026 β€” 2.6.2 release' }, diff --git a/webui/static/style.css b/webui/static/style.css index 73c3aa7b..8c253041 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -13264,6 +13264,53 @@ body.helper-mode-active #dashboard-activity-feed:hover { background-image: url('data:image/svg+xml;charset=utf-8,'); } +.listenbrainz-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.sync-tab-button.active .listenbrainz-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +/* ListenBrainz Sync tab sub-tabs (For You / My Playlists / Collaborative) */ +.listenbrainz-sub-tabs { + display: inline-flex; + gap: 6px; + margin-left: 16px; +} + +.listenbrainz-sub-tab-btn { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; +} + +.listenbrainz-sub-tab-btn:hover { + background: rgba(235, 116, 59, 0.15); + color: #fff; + border-color: rgba(235, 116, 59, 0.4); +} + +.listenbrainz-sub-tab-btn.active { + background: rgba(235, 116, 59, 0.25); + color: #fff; + border-color: rgba(235, 116, 59, 0.6); +} + +.refresh-button.listenbrainz { + background: rgba(235, 116, 59, 0.15); + border: 1px solid rgba(235, 116, 59, 0.4); + color: #fff; +} +.refresh-button.listenbrainz:hover { + background: rgba(235, 116, 59, 0.3); +} + .itunes-icon { background-image: url('data:image/svg+xml;charset=utf-8,'); } diff --git a/webui/static/sync-listenbrainz.js b/webui/static/sync-listenbrainz.js new file mode 100644 index 00000000..d43ad46d --- /dev/null +++ b/webui/static/sync-listenbrainz.js @@ -0,0 +1,238 @@ +// =================================================================== +// LISTENBRAINZ SYNC TAB +// =================================================================== +// Phase 1c.1 of the Discover-to-Sync unification. Renders the user's +// cached ListenBrainz playlists as a Sync-page tab so they participate +// in the same discovery β†’ mirror β†’ auto-sync pipeline as Spotify / +// Tidal / Qobuz / etc. β€” without forcing the user to detour through +// the Discover page. +// +// All the heavy lifting (modal, discovery state machine, sync) already +// lives in sync-services.js + discover.js. This file is just the +// Sync-page entry point: list the cached playlists, render cards, +// pre-fetch tracks on click, then hand off to +// ``openDownloadModalForListenBrainzPlaylist`` which owns the rest. + +let _lbSyncCurrentType = 'created_for_user'; +let _lbSyncPlaylistsByType = {}; // {type: [playlist...]} cache + +async function loadListenBrainzSyncPlaylists() { + const container = document.getElementById('listenbrainz-sync-playlist-container'); + const refreshBtn = document.getElementById('listenbrainz-sync-refresh-btn'); + if (!container) return; + + container.innerHTML = `
πŸ”„ Loading ListenBrainz playlists...
`; + if (refreshBtn) { + refreshBtn.disabled = true; + refreshBtn.textContent = 'πŸ”„ Loading...'; + } + + // Fetch all three LB playlist categories in parallel. The Discover + // page does the same; we mirror its behavior for state-cache parity. + try { + const [createdFor, userPl, collab] = await Promise.all([ + fetch('/api/discover/listenbrainz/created-for').then(r => r.json()), + fetch('/api/discover/listenbrainz/user-playlists').then(r => r.json()), + fetch('/api/discover/listenbrainz/collaborative').then(r => r.json()), + ]); + + // Auth-failure responses look like `{success:false, error:'...'}`. + // Surface them to the user instead of pretending the list was empty. + const anyUnauthed = !createdFor.success && ( + (createdFor.error || '').toLowerCase().includes('not authenticated') + ); + if (anyUnauthed) { + container.innerHTML = `
ListenBrainz not connected. Add your token in Settings β†’ Connections to see your playlists here.
`; + return; + } + + _lbSyncPlaylistsByType = { + created_for_user: createdFor.playlists || [], + user_created: userPl.playlists || [], + collaborative: collab.playlists || [], + }; + renderListenBrainzSyncPlaylists(); + + console.log( + `🎧 ListenBrainz Sync tab loaded: ${_lbSyncPlaylistsByType.created_for_user.length} for-you, ` + + `${_lbSyncPlaylistsByType.user_created.length} user, ` + + `${_lbSyncPlaylistsByType.collaborative.length} collaborative` + ); + } catch (err) { + container.innerHTML = `
❌ Error loading ListenBrainz playlists: ${err.message}
`; + if (typeof showToast === 'function') { + showToast(`Error loading ListenBrainz playlists: ${err.message}`, 'error'); + } + } finally { + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = 'πŸ”„ Refresh'; + } + } +} + +function renderListenBrainzSyncPlaylists() { + const container = document.getElementById('listenbrainz-sync-playlist-container'); + if (!container) return; + + const playlists = _lbSyncPlaylistsByType[_lbSyncCurrentType] || []; + if (playlists.length === 0) { + const empty = { + created_for_user: 'No "For You" playlists yet. ListenBrainz publishes Weekly Exploration / Top Discoveries on its own schedule.', + user_created: 'You haven\'t created any ListenBrainz playlists yet.', + collaborative: 'No collaborative playlists.', + }[_lbSyncCurrentType] || 'No playlists.'; + container.innerHTML = `
${empty}
`; + return; + } + + container.innerHTML = playlists.map(p => { + const mbid = p.playlist_mbid || p.id; + const title = p.title || p.name || 'ListenBrainz Playlist'; + const creator = p.creator || 'ListenBrainz'; + const count = p.track_count || 0; + // Reuse listenbrainzPlaylistStates so the modal state survives + // tab switches (matches Discover-page behavior). + const state = (typeof listenbrainzPlaylistStates !== 'undefined' + && listenbrainzPlaylistStates[mbid]) || null; + const phase = state && state.phase ? state.phase : 'fresh'; + const phaseText = (typeof getPhaseText === 'function') + ? getPhaseText(phase) + : (phase === 'fresh' ? 'Ready to discover' : phase); + const phaseColor = (typeof getPhaseColor === 'function') + ? getPhaseColor(phase) + : '#999'; + const buttonText = (typeof getActionButtonText === 'function') + ? getActionButtonText(phase) + : 'Discover'; + + return ` +
+
🎧
+
+
${escapeHtml(title)}
+
+ ${count} tracks + by ${escapeHtml(creator)} + ${phaseText} +
+
+ +
+ `; + }).join(''); + + // Wire click handlers. + container.querySelectorAll('.listenbrainz-playlist-card').forEach(card => { + card.addEventListener('click', () => { + const mbid = card.dataset.lbMbid; + const title = card.dataset.lbTitle; + handleListenBrainzSyncCardClick(mbid, title); + }); + }); +} + +async function handleListenBrainzSyncCardClick(playlistMbid, playlistTitle) { + if (!playlistMbid) { + if (typeof showToast === 'function') showToast('Missing playlist ID', 'error'); + return; + } + + // The Discover-page LB flow expects ``listenbrainzTracksCache[mbid]`` + // to be populated before opening the modal — it pulls tracks from + // there when constructing the discovery state. On the Sync tab the + // user may click an LB card without ever visiting Discover, so we + // fetch + cache the tracks on demand here. + try { + if (typeof showLoadingOverlay === 'function') { + showLoadingOverlay(`Loading ${playlistTitle}...`); + } + + if (typeof listenbrainzTracksCache === 'undefined') { + window.listenbrainzTracksCache = {}; + } + let tracks = listenbrainzTracksCache[playlistMbid]; + if (!tracks || tracks.length === 0) { + const resp = await fetch(`/api/discover/listenbrainz/playlist/${encodeURIComponent(playlistMbid)}`); + if (!resp.ok) { + throw new Error(`Failed to load playlist tracks (${resp.status})`); + } + const data = await resp.json(); + tracks = (data.tracks || []).map(t => ({ + track_name: t.track_name || '', + artist_name: t.artist_name || '', + album_name: t.album_name || '', + duration_ms: t.duration_ms || 0, + mbid: t.recording_mbid || t.mbid || '', + release_mbid: t.release_mbid || '', + album_cover_url: t.album_cover_url || '', + })); + listenbrainzTracksCache[playlistMbid] = tracks; + } + + if (!tracks || tracks.length === 0) { + throw new Error('Playlist has no tracks'); + } + + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); + + // Hand off to the existing Discover-page modal opener. It owns + // state init, discovery kickoff, polling, and the sync→mirror + // step. The Sync tab is just a different entry point. + if (typeof openDownloadModalForListenBrainzPlaylist === 'function') { + await openDownloadModalForListenBrainzPlaylist(playlistMbid, playlistTitle); + } else { + throw new Error('LB discovery modal not available — discover.js may be missing'); + } + } catch (err) { + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); + console.error('Error opening LB playlist from Sync tab:', err); + if (typeof showToast === 'function') { + showToast(`Could not open playlist: ${err.message}`, 'error'); + } + } +} + +// Sub-tab switching (For You / My Playlists / Collaborative). +function _initListenBrainzSyncSubTabs() { + const subTabContainer = document.querySelector('#listenbrainz-tab-content .listenbrainz-sub-tabs'); + if (!subTabContainer) return; + subTabContainer.addEventListener('click', (e) => { + const btn = e.target.closest('.listenbrainz-sub-tab-btn'); + if (!btn) return; + const newType = btn.dataset.lbType; + if (!newType || newType === _lbSyncCurrentType) return; + + subTabContainer.querySelectorAll('.listenbrainz-sub-tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + _lbSyncCurrentType = newType; + renderListenBrainzSyncPlaylists(); + }); +} + +// Refresh button. +function _initListenBrainzSyncRefreshBtn() { + const btn = document.getElementById('listenbrainz-sync-refresh-btn'); + if (!btn) return; + btn.addEventListener('click', async () => { + // Trigger backend refetch + re-render. + try { + await fetch('/api/discover/listenbrainz/refresh', { method: 'POST' }); + } catch (e) { + // Non-fatal; we still re-load from the cache endpoints. + console.warn('LB cache refresh failed (non-fatal):', e); + } + loadListenBrainzSyncPlaylists(); + }); +} + +// Bootstrap once when sync page DOM is ready. ``initializeSyncPage`` +// runs at app boot; we hook our subtab + refresh listeners on top. +document.addEventListener('DOMContentLoaded', () => { + _initListenBrainzSyncSubTabs(); + _initListenBrainzSyncRefreshBtn(); +}); diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 71b2d1ed..e1ab023f 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -3731,6 +3731,15 @@ function initializeSyncPage() { if (tabId === 'beatport') { ensureBeatportContentLoaded(); } + + // Auto-load ListenBrainz Sync-tab playlists on first activation. + // Reuses the LB discovery + sync flow already wired up for the + // Discover page — the tab is purely a Sync-page entry point. + if (tabId === 'listenbrainz' && typeof loadListenBrainzSyncPlaylists === 'function' + && !window._listenbrainzSyncTabLoaded) { + window._listenbrainzSyncTabLoaded = true; + loadListenBrainzSyncPlaylists(); + } }); });