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.