Add ListenBrainz tab to Sync page (Phase 1c.1)

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 ``<button data-tab="listenbrainz">`` +
  tab content container with "For You / My Playlists /
  Collaborative" sub-tabs and a refresh button.
- ``webui/static/sync-listenbrainz.js`` (new): ``loadListenBrainz
  SyncPlaylists`` fetches all three LB cache categories in parallel,
  ``renderListenBrainzSyncPlaylists`` renders cards in the standard
  ``.youtube-playlist-card`` shell with the existing phase-state
  helpers (so card colors / button text stay consistent with Tidal
  / Qobuz / etc.). Click handler populates the
  ``listenbrainzTracksCache`` from
  ``/api/discover/listenbrainz/playlist/<mbid>`` if not already
  primed, then defers to the shared modal opener.
- ``webui/static/sync-services.js``: one new branch in
  ``initializeSyncPage`` to lazy-load the tab on first activation.
- ``webui/static/style.css``: ``.listenbrainz-icon`` SVG (orange
  play-button in circle for inactive, white for active),
  ``.listenbrainz-sub-tab-btn`` styling for the sub-tabs,
  ``.refresh-button.listenbrainz`` accent.
- ``webui/static/helper.js``: WHATS_NEW entry under 2.6.3.

Auth-not-connected case is surfaced as a friendly placeholder
pointing the user at Settings → Connections instead of an empty
list.
pull/709/head
Broque Thomas 1 month ago
parent 246503066b
commit a7053a6061

@ -1027,6 +1027,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="listenbrainz">
<span class="tab-icon listenbrainz-icon"></span> ListenBrainz
</button>
<button class="sync-tab-button" data-tab="import-file">
<span class="tab-icon import-file-icon"></span> Import
</button>
@ -1912,6 +1915,22 @@
</div>
</div>
<!-- ListenBrainz Tab Content -->
<div class="sync-tab-content" id="listenbrainz-tab-content">
<div class="playlist-header">
<h3>Your ListenBrainz Playlists</h3>
<div class="listenbrainz-sub-tabs">
<button class="listenbrainz-sub-tab-btn active" data-lb-type="created_for_user">For You</button>
<button class="listenbrainz-sub-tab-btn" data-lb-type="user_created">My Playlists</button>
<button class="listenbrainz-sub-tab-btn" data-lb-type="collaborative">Collaborative</button>
</div>
<button class="refresh-button listenbrainz" id="listenbrainz-sync-refresh-btn">🔄 Refresh</button>
</div>
<div class="playlist-scroll-container" id="listenbrainz-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your ListenBrainz playlists.</div>
</div>
</div>
<!-- Mirrored Playlists Tab Content -->
<div class="sync-tab-content" id="mirrored-tab-content">
<div class="playlist-header">
@ -7962,6 +7981,7 @@
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<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='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>

@ -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' },

@ -13264,6 +13264,53 @@ 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="%2301FF95"><path d="M2 6h20v2H2zm0 5h20v2H2zm0 5h20v2H2z"/></svg>');
}
.listenbrainz-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="%23eb743b"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm-1 13.5v-9l6 4.5-6 4.5z"/></svg>');
}
.sync-tab-button.active .listenbrainz-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 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm-1 13.5v-9l6 4.5-6 4.5z"/></svg>');
}
/* 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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="%23fa233b"/><path fill="%23ffffff" d="M16.5 6v8.8a2.6 2.6 0 1 1-1.4-2.3V8.6l-5.4 1.4v6.3a2.6 2.6 0 1 1-1.4-2.3V8.7L16.5 6z"/></svg>');
}

@ -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 = `<div class="playlist-placeholder">🔄 Loading ListenBrainz playlists...</div>`;
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 = `<div class="playlist-placeholder">ListenBrainz not connected. Add your token in Settings → Connections to see your playlists here.</div>`;
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 = `<div class="playlist-placeholder">❌ Error loading ListenBrainz playlists: ${err.message}</div>`;
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 = `<div class="playlist-placeholder">${empty}</div>`;
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 `
<div class="youtube-playlist-card listenbrainz-playlist-card"
id="listenbrainz-sync-card-${escapeHtml(mbid)}"
data-lb-mbid="${escapeHtml(mbid)}"
data-lb-title="${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">by ${escapeHtml(creator)}</span>
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
</div>
</div>
<button class="playlist-card-action-btn">${buttonText}</button>
</div>
`;
}).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();
});

@ -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();
}
});
});

Loading…
Cancel
Save