discover: Last.fm Radio + ListenBrainz -> mix cards; remove Browse-by-Genre

buildListenBrainzPlaylistsHtml (shared by both sections) now emits a grid of mix cards instead of
full-width track-table subsections — each playlist is a card that opens its tracks (lazy-loaded from
/api/discover/listenbrainz/playlist/{id}) + Download/Sync in the shared modal. ListenBrainz sync uses
its own -sync-total/-sync-matched status spans, so the mix carries a matching statusHtml; the sync
button id (discover-lb-playlist-{id}-sync-btn) lines up so startListenBrainzPlaylistSync drives it.
The old eager loadTracksForPlaylists calls are now harmless no-ops (their -playlist containers are
gone, so loadListenBrainzPlaylistTracks early-returns). Wing-It is omitted from the card modal for now
(its dropdown needs modal-aware positioning).

Browse by Genre removed (section markup + the loadGenreBrowserTabs call): it was empty
(/api/discover/genres/available returns nothing) and redundant with the Genre Explorer pills.

64 script-integrity tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-29 15:17:07 -07:00
parent 153fad152c
commit ec4439da00
2 changed files with 46 additions and 86 deletions

View file

@ -3797,24 +3797,8 @@
<div id="decade-tab-contents"></div> <div id="decade-tab-contents"></div>
</div> </div>
<!-- Browse by Genre (Tabbed by Genre) --> <!-- Browse by Genre removed (#discover redesign): it was empty (no data from
<div class="discover-section"> /api/discover/genres/available) and redundant with the Genre Explorer pills. -->
<div class="discover-section-header">
<h2 class="discover-section-title">🎵 Browse by Genre</h2>
<p class="discover-section-subtitle">Discover music by your favorite genres</p>
</div>
<!-- Genre Tabs (will be populated dynamically) -->
<div class="genre-tabs" id="genre-tabs">
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading genres...</p>
</div>
</div>
<!-- Genre Tab Contents (will be populated dynamically) -->
<div id="genre-tab-contents"></div>
</div>
</div> </div>
</div> </div>

View file

@ -50,7 +50,7 @@ async function loadDiscoverPage() {
initializeLastfmRadioSection(), // Last.fm Radio section (gated on API key) initializeLastfmRadioSection(), // Last.fm Radio section (gated on API key)
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed) initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade) loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre) // loadGenreBrowserTabs(), // REMOVED (#discover redesign): empty + redundant with Genre Explorer
loadListenBrainzPlaylistsFromBackend(), // Load ListenBrainz playlist states for persistence loadListenBrainzPlaylistsFromBackend(), // Load ListenBrainz playlist states for persistence
loadDiscoveryBlacklist() // Blocked artists list loadDiscoveryBlacklist() // Blocked artists list
]); ]);
@ -3304,84 +3304,58 @@ function groupListenBrainzPlaylists(playlists) {
} }
function buildListenBrainzPlaylistsHtml(playlists, tabId) { function buildListenBrainzPlaylistsHtml(playlists, tabId) {
let html = ''; // #discover redesign: each playlist is a mix card (opens its tracks + actions in the shared
playlists.forEach((playlist, index) => { // modal) instead of a full-width track-table subsection. Shared by Last.fm Radio + ListenBrainz.
const icon = tabId === 'lastfm_radio' ? '\U0001F4FB' : '\U0001F3A7';
const mixes = playlists.map(playlist => {
const playlistData = playlist.playlist || playlist; const playlistData = playlist.playlist || playlist;
const identifier = playlistData.identifier?.split('/').pop() || ''; const identifier = playlistData.identifier?.split('/').pop() || '';
console.log(`📋 Playlist ${index}:`, {
title: playlistData.title,
fullIdentifier: playlistData.identifier,
extractedIdentifier: identifier
});
const title = playlistData.title || 'Untitled Playlist'; const title = playlistData.title || 'Untitled Playlist';
const creator = playlistData.creator || 'ListenBrainz'; const creator = playlistData.creator || 'ListenBrainz';
let trackCount = 50; let trackCount = 50;
if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) { if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) {
trackCount = playlistData.annotation.track_count; trackCount = playlistData.annotation.track_count;
} else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) { } else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) {
trackCount = playlistData.track.length; trackCount = playlistData.track.length;
} }
const escTitle = escapeForInlineJs(title);
const playlistId = `discover-lb-playlist-${identifier}`; // Use consistent MBID-based ID // playlistId is the status base startListenBrainzPlaylistSync targets; LB uses its own
const virtualPlaylistId = `discover_lb_${tabId}_${identifier}`; // -sync-total/-sync-matched spans, so we hand the modal a matching statusHtml block.
const playlistId = `discover-lb-playlist-${identifier}`;
html += ` const statusHtml = `
<div class="discover-section-subsection"> <div class="discover-sync-status" id="${playlistId}-sync-status" style="display:none">
<div class="discover-section-header"> <div class="sync-status-content">
<div> <div class="sync-status-label"><span class="sync-icon">&#10227;</span><span>Syncing to media server...</span></div>
<h3 class="discover-section-subtitle-large">${title}</h3> <div class="sync-status-stats">
<p class="discover-section-meta" id="${playlistId}-meta">by ${creator} Loading tracks...</p> <span class="sync-stat">&#9834; <span id="${playlistId}-sync-total">0</span></span>
</div> <span class="sync-separator">/</span>
<div class="discover-section-actions"> <span class="sync-stat">&#10003; <span id="${playlistId}-sync-matched">0</span></span>
<button class="action-button secondary" <span class="sync-separator">/</span>
onclick="openDownloadModalForListenBrainzPlaylist('${identifier}', '${escapeForInlineJs(title)}')" <span class="sync-stat">&#10007; <span id="${playlistId}-sync-failed">0</span></span>
title="Download missing tracks"> <span class="sync-stat">(<span id="${playlistId}-sync-percentage">0</span>%)</span>
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<span class="wing-it-wrap">
<button class="action-button wing-it-btn-sm"
onclick="_toggleWingItDropdownLB(this, '${identifier}', '${escapeForInlineJs(title)}')"
title="Download or sync using raw track names — no metadata discovery">
<span class="button-icon"></span>
<span class="button-text">Wing It</span>
</button>
</span>
<button class="action-button primary"
id="${playlistId}-sync-btn"
onclick="startListenBrainzPlaylistSync('${identifier}')"
title="Sync to media server"
style="display: none;">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
</div> </div>
</div> </div>
<!-- Sync Status Display --> </div>`;
<div class="discover-sync-status" id="${playlistId}-sync-status" style="display: none;"> return {
<div class="sync-status-content"> key: `lb-${tabId}-${identifier}`,
<div class="sync-status-label"> title, subtitle: `by ${creator}`, trackCount,
<span class="sync-icon"></span> coverHtml: `<div class="mix-card-decade"><span class="mix-card-decade-icon">${icon}</span></div>`,
<span>Syncing to media server...</span> statusBase: playlistId,
</div> statusHtml,
<div class="sync-status-stats"> actions: [
<span class="sync-stat"> <span id="${playlistId}-sync-total">0</span></span> { label: 'Download', closeFirst: true, onclick: `openDownloadModalForListenBrainzPlaylist('${identifier}', '${escTitle}')` },
<span class="sync-separator">/</span> { label: 'Sync', primary: true, isSync: true, onclick: `startListenBrainzPlaylistSync('${identifier}')` },
<span class="sync-stat"> <span id="${playlistId}-sync-matched">0</span></span> ],
<span class="sync-separator">/</span> // Tracks load lazily on open; cache them where displayListenBrainzTracks would so reuse works.
<span class="sync-stat"> <span id="${playlistId}-sync-failed">0</span></span> fetchTracks: () => fetch(`/api/discover/listenbrainz/playlist/${identifier}`).then(r => r.json()).then(d => {
<span class="sync-stat">(<span id="${playlistId}-sync-percentage">0</span>%)</span> const tracks = (d && d.tracks) || [];
</div> listenbrainzTracksCache[identifier] = tracks;
</div> return tracks;
</div> }),
<div class="discover-playlist-container compact" id="${playlistId}-playlist"> };
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading tracks...</p></div>
</div>
</div>
`;
}); });
return html; mixes.forEach(m => { _discoverMixRegistry[m.key] = m; });
return `<div class="discover-grid">${mixes.map(_buildMixCard).join('')}</div>`;
} }
function loadTracksForPlaylists(playlists) { function loadTracksForPlaylists(playlists) {
@ -4550,7 +4524,9 @@ function openMixModal(mix) {
const oc = (a.closeFirst ? closeFirst : '') + a.onclick; const oc = (a.closeFirst ? closeFirst : '') + a.onclick;
return `<button class="${cls}"${idAttr} onclick="${oc}">${_esc(a.label)}</button>`; return `<button class="${cls}"${idAttr} onclick="${oc}">${_esc(a.label)}</button>`;
}).join(''); }).join('');
const syncStatus = base ? ` // A section can supply its own status markup (e.g. ListenBrainz uses -sync-total/-sync-matched
// spans instead of the generic -sync-completed/-sync-pending). Otherwise use the generic block.
const syncStatus = mix.statusHtml || (base ? `
<div class="discover-sync-status" id="${base}-sync-status" style="display:none"> <div class="discover-sync-status" id="${base}-sync-status" style="display:none">
<div class="sync-status-content"> <div class="sync-status-content">
<div class="sync-status-label"><span class="sync-icon">&#10227;</span><span>Syncing to media server...</span></div> <div class="sync-status-label"><span class="sync-icon">&#10227;</span><span>Syncing to media server...</span></div>
@ -4561,7 +4537,7 @@ function openMixModal(mix) {
<span class="sync-stat">(<span id="${base}-sync-percentage">0</span>%)</span> <span class="sync-stat">(<span id="${base}-sync-percentage">0</span>%)</span>
</div> </div>
</div> </div>
</div>` : ''; </div>` : '');
overlay.innerHTML = ` overlay.innerHTML = `
<div class="mix-modal"> <div class="mix-modal">
<div class="mix-modal-header"> <div class="mix-modal-header">