From 4ee78bb973d4de3848a9182c0358537c1127b7a2 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 7 May 2026 19:21:19 -0700 Subject: [PATCH] Migrate 7 more discover sections to the shared controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the foundation commit. Drops the hand-rolled try/catch + spinner injection + empty-state HTML + error-swallow in seven sections by routing them through `createDiscoverSectionController`. Each section keeps its existing public function name + signature so callers, refresh buttons, and dashboard wiring don't notice the swap. Migrated: - `loadDiscoverReleaseRadar` (Fresh Tape) - `loadDiscoverWeekly` (The Archives) - `loadDecadeBrowser` (Time Machine intro carousel) - `loadGenreBrowser` (Browse by Genre intro carousel) - `loadSeasonalPlaylist` (Seasonal Mix) - `loadYourArtists` - `loadBecauseYouListenTo` Skipped (don't fit the controller's single-fetch / single-render-target shape): - `loadYourAlbums` — paginated grid + filters, updates four separate UI elements (subtitle, filter chips, download button, grid). - `loadSeasonalAlbums` — receives pre-fetched data from `loadSeasonalContent`; no fetch URL to satisfy. Hidden / dead sections (~13 of them — `loadPersonalized*`, `loadDiscoveryShuffle`, `loadFamiliarFavorites`, `loadCache*`) untouched in this pass. Separate audit commit will surface or kill them. Two side-effects worth noting: - `loadDecadeBrowser` and `loadGenreBrowser` migrated for completeness, but neither appears wired into `loadDiscoverPage` or any inline handler. May be dead code — flagged for the audit pass. - `loadSeasonalPlaylist` needs a per-load fetch URL (varies by `currentSeasonKey`); worked around by recreating the controller when the key changes. Cleaner option: extend the controller to accept a `fetchUrl: () => string` callable form. Tracked in the follow-up extension list below. Controller extension candidates surfaced for follow-up: - Callable `fetchUrl` (resolves the seasonal playlist recreate-on-key-change hack) - Explicit `isStale` / `onStale` hook (so Your Artists doesn't fold stale handling into renderItems) - `beforeLoad` / `ensureContentEl` hook (so Because You Listen To can let the controller own the dynamic container creation) - No-fetch `data:` mode (so render-only sections like Seasonal Albums can use the controller too) - `onSuccess(data)` hook (cleaner home for header / subtitle side-effects vs folding them into renderItems) Net: -76 lines in `discover.js` even after adding the per-section render helpers. 2204/2204 full suite green. JS parses clean. --- webui/static/discover.js | 622 +++++++++++++++++---------------------- webui/static/helper.js | 1 + 2 files changed, 274 insertions(+), 349 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 77e1e453..20bbd8c5 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -1329,118 +1329,71 @@ async function downloadMissingYourAlbums() { } -async function loadDiscoverReleaseRadar() { - try { - const playlistContainer = document.getElementById('release-radar-playlist'); - if (!playlistContainer) return; - - playlistContainer.innerHTML = '

Loading release radar...

'; - - const response = await fetch('/api/discover/release-radar'); - if (!response.ok) { - throw new Error('Failed to fetch release radar'); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - playlistContainer.innerHTML = '

No new releases available

'; - return; - } - - // Store tracks for download/sync functionality - discoverReleaseRadarTracks = data.tracks; +function _renderCompactTrackRow(track, index) { + const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; + const durationMin = Math.floor(track.duration_ms / 60000); + const durationSec = Math.floor((track.duration_ms % 60000) / 1000); + const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + return ` +
+
${index + 1}
+
+ ${track.album_name} +
+
+
${track.track_name}
+
${track.artist_name}
+
+
${track.album_name}
+
${duration}
+
+ `; +} - // Build compact playlist HTML - let html = '
'; - data.tracks.forEach((track, index) => { - const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; - const durationMin = Math.floor(track.duration_ms / 60000); - const durationSec = Math.floor((track.duration_ms % 60000) / 1000); - const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; +let _releaseRadarCtrl = null; - html += ` -
-
${index + 1}
-
- ${track.album_name} -
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
-
- `; +async function loadDiscoverReleaseRadar() { + if (!_releaseRadarCtrl) { + _releaseRadarCtrl = createDiscoverSectionController({ + id: 'release-radar', + contentEl: '#release-radar-playlist', + fetchUrl: '/api/discover/release-radar', + extractItems: (data) => data.tracks || [], + renderItems: (items) => { + discoverReleaseRadarTracks = items; + const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join(''); + return `
${rows}
`; + }, + loadingMessage: 'Loading release radar...', + emptyMessage: 'No new releases available', + errorMessage: 'Failed to load release radar', + verboseErrors: true, }); - html += '
'; - - playlistContainer.innerHTML = html; - - } catch (error) { - console.error('Error loading release radar:', error); - const playlistContainer = document.getElementById('release-radar-playlist'); - if (playlistContainer) { - playlistContainer.innerHTML = '

Failed to load release radar

'; - } } + return _releaseRadarCtrl.load(); } -async function loadDiscoverWeekly() { - try { - const playlistContainer = document.getElementById('discovery-weekly-playlist'); - if (!playlistContainer) return; - - playlistContainer.innerHTML = '

Curating your discovery playlist...

'; - - const response = await fetch('/api/discover/weekly'); - if (!response.ok) { - throw new Error('Failed to fetch discovery weekly'); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - playlistContainer.innerHTML = '

No tracks available yet

'; - return; - } - - // Store tracks for download/sync functionality - discoverWeeklyTracks = data.tracks; - - // Build compact playlist HTML - let html = '
'; - data.tracks.forEach((track, index) => { - const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; - const durationMin = Math.floor(track.duration_ms / 60000); - const durationSec = Math.floor((track.duration_ms % 60000) / 1000); - const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; +let _weeklyCtrl = null; - html += ` -
-
${index + 1}
-
- ${track.album_name} -
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
-
- `; +async function loadDiscoverWeekly() { + if (!_weeklyCtrl) { + _weeklyCtrl = createDiscoverSectionController({ + id: 'discovery-weekly', + contentEl: '#discovery-weekly-playlist', + fetchUrl: '/api/discover/weekly', + extractItems: (data) => data.tracks || [], + renderItems: (items) => { + discoverWeeklyTracks = items; + const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join(''); + return `
${rows}
`; + }, + loadingMessage: 'Curating your discovery playlist...', + emptyMessage: 'No tracks available yet', + errorMessage: 'Failed to load discovery weekly', + verboseErrors: true, }); - html += '
'; - - playlistContainer.innerHTML = html; - - } catch (error) { - console.error('Error loading discovery weekly:', error); - const playlistContainer = document.getElementById('discovery-weekly-playlist'); - if (playlistContainer) { - playlistContainer.innerHTML = '

Failed to load discovery weekly

'; - } } + return _weeklyCtrl.load(); } // =============================== @@ -1450,51 +1403,40 @@ async function loadDiscoverWeekly() { let selectedDecade = null; let decadeTracks = []; -async function loadDecadeBrowser() { - try { - const carousel = document.getElementById('decade-browser-carousel'); - if (!carousel) return; - - // Fetch available decades from backend - const response = await fetch('/api/discover/decades/available'); - if (!response.ok) { - throw new Error('Failed to fetch available decades'); - } +function _renderDecadeCard(decade) { + const icon = getDecadeIcon(decade.year); + const label = `${decade.year}s`; + return ` +
+
+
${icon}
+
+
+

${label}

+

${decade.track_count} tracks

+

Classics

+
+
+ `; +} - const data = await response.json(); - if (!data.success || !data.decades || data.decades.length === 0) { - carousel.innerHTML = '

No decade content available yet. Run a watchlist scan to populate your discovery pool!

'; - return; - } +let _decadeBrowserCtrl = null; - // Build decade cards matching Recent Releases style - let html = ''; - data.decades.forEach(decade => { - const icon = getDecadeIcon(decade.year); - const label = `${decade.year}s`; - html += ` -
-
-
${icon}
-
-
-

${label}

-

${decade.track_count} tracks

-

Classics

-
-
- `; +async function loadDecadeBrowser() { + if (!_decadeBrowserCtrl) { + _decadeBrowserCtrl = createDiscoverSectionController({ + id: 'decade-browser', + contentEl: '#decade-browser-carousel', + fetchUrl: '/api/discover/decades/available', + extractItems: (data) => data.decades || [], + renderItems: (items) => items.map(d => _renderDecadeCard(d)).join(''), + loadingMessage: 'Loading decades...', + emptyMessage: 'No decade content available yet. Run a watchlist scan to populate your discovery pool!', + errorMessage: 'Failed to load decades', + verboseErrors: true, }); - - carousel.innerHTML = html; - - } catch (error) { - console.error('Error loading decade browser:', error); - const carousel = document.getElementById('decade-browser-carousel'); - if (carousel) { - carousel.innerHTML = '

Failed to load decades

'; - } } + return _decadeBrowserCtrl.load(); } function getDecadeIcon(year) { @@ -1552,51 +1494,40 @@ async function openDecadePlaylist(decade) { let selectedGenre = null; let genreTracks = []; -async function loadGenreBrowser() { - try { - const carousel = document.getElementById('genre-browser-carousel'); - if (!carousel) return; - - // Fetch available genres from backend - const response = await fetch('/api/discover/genres/available'); - if (!response.ok) { - throw new Error('Failed to fetch available genres'); - } +function _renderGenreCard(genre) { + const icon = getGenreIcon(genre.name); + const displayName = capitalizeGenre(genre.name); + return ` +
+
+
${icon}
+
+
+

${displayName}

+

${genre.track_count} tracks

+

Curated

+
+
+ `; +} - const data = await response.json(); - if (!data.success || !data.genres || data.genres.length === 0) { - carousel.innerHTML = '

No genre content available yet. Run a watchlist scan to populate your discovery pool!

'; - return; - } +let _genreBrowserCtrl = null; - // Build genre cards matching Recent Releases style - let html = ''; - data.genres.forEach(genre => { - const icon = getGenreIcon(genre.name); - const displayName = capitalizeGenre(genre.name); - html += ` -
-
-
${icon}
-
-
-

${displayName}

-

${genre.track_count} tracks

-

Curated

-
-
- `; +async function loadGenreBrowser() { + if (!_genreBrowserCtrl) { + _genreBrowserCtrl = createDiscoverSectionController({ + id: 'genre-browser', + contentEl: '#genre-browser-carousel', + fetchUrl: '/api/discover/genres/available', + extractItems: (data) => data.genres || [], + renderItems: (items) => items.map(g => _renderGenreCard(g)).join(''), + loadingMessage: 'Loading genres...', + emptyMessage: 'No genre content available yet. Run a watchlist scan to populate your discovery pool!', + errorMessage: 'Failed to load genres', + verboseErrors: true, }); - - carousel.innerHTML = html; - - } catch (error) { - console.error('Error loading genre browser:', error); - const carousel = document.getElementById('genre-browser-carousel'); - if (carousel) { - carousel.innerHTML = '

Failed to load genres

'; - } } + return _genreBrowserCtrl.load(); } function getGenreIcon(genreName) { @@ -3657,80 +3588,51 @@ async function loadSeasonalAlbums(seasonData) { } } -async function loadSeasonalPlaylist(seasonData) { - try { - const playlistContainer = document.getElementById('seasonal-playlist'); - if (!playlistContainer) return; - - // Show seasonal playlist section - const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section'); - if (seasonalPlaylistSection) { - seasonalPlaylistSection.style.display = 'block'; - } - - // Update header - const playlistTitle = document.getElementById('seasonal-playlist-title'); - const playlistSubtitle = document.getElementById('seasonal-playlist-subtitle'); - - if (playlistTitle) { - playlistTitle.textContent = `${seasonData.icon} ${seasonData.name} Mix`; - } - if (playlistSubtitle) { - playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`; - } - - playlistContainer.innerHTML = '

Loading playlist...

'; - - // Fetch playlist tracks - const response = await fetch(`/api/discover/seasonal/${currentSeasonKey}/playlist`); - if (!response.ok) { - throw new Error('Failed to fetch seasonal playlist'); - } +let _seasonalPlaylistCtrl = null; +let _seasonalPlaylistCtrlKey = null; - const data = await response.json(); +async function loadSeasonalPlaylist(seasonData) { + const playlistContainer = document.getElementById('seasonal-playlist'); + if (!playlistContainer) return; - if (!data.success || !data.tracks || data.tracks.length === 0) { - playlistContainer.innerHTML = '

No tracks available yet

'; - return; - } + // Show seasonal playlist section + const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section'); + if (seasonalPlaylistSection) { + seasonalPlaylistSection.style.display = 'block'; + } - // Store tracks for download/sync functionality - discoverSeasonalTracks = data.tracks; + // Update header + const playlistTitle = document.getElementById('seasonal-playlist-title'); + const playlistSubtitle = document.getElementById('seasonal-playlist-subtitle'); - // Build compact playlist HTML - let html = '
'; - data.tracks.forEach((track, index) => { - const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; - const durationMin = Math.floor(track.duration_ms / 60000); - const durationSec = Math.floor((track.duration_ms % 60000) / 1000); - const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + if (playlistTitle) { + playlistTitle.textContent = `${seasonData.icon} ${seasonData.name} Mix`; + } + if (playlistSubtitle) { + playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`; + } - html += ` -
-
${index + 1}
-
- ${track.album_name} -
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
-
- `; + // Re-create the controller when the season key changes so the + // fetchUrl always points at the active season's endpoint. + if (!_seasonalPlaylistCtrl || _seasonalPlaylistCtrlKey !== currentSeasonKey) { + _seasonalPlaylistCtrl = createDiscoverSectionController({ + id: 'seasonal-playlist', + contentEl: '#seasonal-playlist', + fetchUrl: `/api/discover/seasonal/${currentSeasonKey}/playlist`, + extractItems: (data) => data.tracks || [], + renderItems: (items) => { + discoverSeasonalTracks = items; + const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join(''); + return `
${rows}
`; + }, + loadingMessage: 'Loading playlist...', + emptyMessage: 'No tracks available yet', + errorMessage: 'Failed to load playlist', + verboseErrors: true, }); - html += '
'; - - playlistContainer.innerHTML = html; - - } catch (error) { - console.error('Error loading seasonal playlist:', error); - const playlistContainer = document.getElementById('seasonal-playlist'); - if (playlistContainer) { - playlistContainer.innerHTML = '

Failed to load playlist

'; - } + _seasonalPlaylistCtrlKey = currentSeasonKey; } + return _seasonalPlaylistCtrl.load(); } function hideSeasonalSections() { @@ -4225,59 +4127,63 @@ async function unblockDiscoveryArtist(id, name) { // Backwards compat — called during page init but now a no-op (modal handles it) // ── Your Artists (Liked Artists Pool) ── -async function loadYourArtists() { - const section = document.getElementById('your-artists-section'); - const carousel = document.getElementById('your-artists-carousel'); - const subtitle = document.getElementById('your-artists-subtitle'); - if (!section || !carousel) return; - - try { - const resp = await fetch('/api/discover/your-artists'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.artists || data.artists.length === 0) { - if (data.stale) { - // First load — show section with loading state, poll until ready - section.style.display = ''; - if (subtitle) subtitle.textContent = 'Discovering your artists across connected services...'; - carousel.innerHTML = ` -
-
- Fetching and matching artists from your services... -
- `; - _pollYourArtists(); - } else { - section.style.display = 'none'; - } - return; - } - - // Show section - section.style.display = ''; - - // Update subtitle with source info - const sources = new Set(); - data.artists.forEach(a => (a.source_services || []).forEach(s => sources.add(s))); - const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' }; - const sourceList = [...sources].map(s => sourceNames[s] || s).join(' and '); - if (subtitle) subtitle.textContent = `Artists you follow on ${sourceList || 'your music services'}`; +let _yourArtistsCtrl = null; - if (data.stale) { - if (subtitle) subtitle.textContent += ' (updating...)'; - _pollYourArtists(); - } +async function loadYourArtists() { + if (!_yourArtistsCtrl) { + _yourArtistsCtrl = createDiscoverSectionController({ + id: 'your-artists', + sectionEl: '#your-artists-section', + contentEl: '#your-artists-carousel', + fetchUrl: '/api/discover/your-artists', + extractItems: (data) => data.artists || [], + // Only treat as "truly empty" when there's no data AND the + // upstream isn't still discovering. When stale + empty, the + // renderer shows a custom in-progress message and a poller + // is started in onRendered. + isEmpty: (items, data) => items.length === 0 && !data.stale, + hideWhenEmpty: true, + renderItems: (items, data) => { + const subtitle = document.getElementById('your-artists-subtitle'); + + // Stale + empty — show custom "still fetching" message + if (items.length === 0 && data.stale) { + if (subtitle) subtitle.textContent = 'Discovering your artists across connected services...'; + return ` +
+
+ Fetching and matching artists from your services... +
+ `; + } - // Store for modal access and render carousel cards - window._yaArtists = {}; - window._yaActiveSource = data.active_source || 'spotify'; - data.artists.forEach(a => { window._yaArtists[a.id] = a; }); - carousel.innerHTML = data.artists.map(a => _renderYourArtistCard(a)).join(''); + // Update subtitle with source info + const sources = new Set(); + items.forEach(a => (a.source_services || []).forEach(s => sources.add(s))); + const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' }; + const sourceList = [...sources].map(s => sourceNames[s] || s).join(' and '); + if (subtitle) { + subtitle.textContent = `Artists you follow on ${sourceList || 'your music services'}`; + if (data.stale) subtitle.textContent += ' (updating...)'; + } - } catch (err) { - console.error('Error loading Your Artists:', err); + // Store for modal access and render carousel cards + window._yaArtists = {}; + window._yaActiveSource = data.active_source || 'spotify'; + items.forEach(a => { window._yaArtists[a.id] = a; }); + return items.map(a => _renderYourArtistCard(a)).join(''); + }, + onRendered: ({ data }) => { + // Continue polling while upstream is still discovering. + if (data.stale) _pollYourArtists(); + }, + loadingMessage: 'Loading your artists...', + emptyMessage: 'No followed artists found', + errorMessage: 'Failed to load your artists', + verboseErrors: true, + }); } + return _yourArtistsCtrl.load(); } function _pollYourArtists() { @@ -6685,60 +6591,78 @@ async function loadFamiliarFavorites() { // BECAUSE YOU LISTEN TO // =============================== -async function loadBecauseYouListenTo() { - try { - const resp = await fetch('/api/discover/because-you-listen-to'); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success || !data.sections || data.sections.length === 0) return; - - // Find or create the BYLT container - let byltContainer = document.getElementById('discover-bylt-sections'); - if (!byltContainer) { - // Insert after the release radar section - const releaseRadar = document.getElementById('discover-release-radar'); - if (!releaseRadar) return; - const parent = releaseRadar.closest('.discover-section'); - if (!parent) return; - - byltContainer = document.createElement('div'); - byltContainer.id = 'discover-bylt-sections'; - parent.parentNode.insertBefore(byltContainer, parent.nextSibling); - } - - byltContainer.innerHTML = data.sections.map((section, idx) => ` -
-
-
- ${section.artist_image ? `` : ''} -
-
Because you listen to
-

${_esc(section.artist_name)}

-
+function _renderByltSection(section, idx) { + return ` +
+
+
+ ${section.artist_image ? `` : ''} +
+
Because you listen to
+

${_esc(section.artist_name)}

-
- `).join(''); + +
+ `; +} - // Render track cards in each carousel - data.sections.forEach((section, idx) => { - const carousel = document.getElementById(`bylt-carousel-${idx}`); - if (!carousel) return; - carousel.innerHTML = section.tracks.map(t => ` -
-
- ${t.image_url ? `` : '
🎵
'} -
-
${_esc(t.name)}
-
${_esc(t.artist)}
-
- `).join(''); - }); +function _renderByltTrackCard(t) { + return ` +
+
+ ${t.image_url ? `` : '
🎵
'} +
+
${_esc(t.name)}
+
${_esc(t.artist)}
+
+ `; +} - } catch (error) { - console.debug('Error loading Because You Listen To:', error); +let _byltCtrl = null; + +async function loadBecauseYouListenTo() { + // Ensure the BYLT container exists in the DOM. It's dynamically + // inserted after the release radar section because the markup + // doesn't ship a placeholder for it. Bail if anchor section + // isn't present. + let byltContainer = document.getElementById('discover-bylt-sections'); + if (!byltContainer) { + const releaseRadar = document.getElementById('discover-release-radar'); + if (!releaseRadar) return; + const parent = releaseRadar.closest('.discover-section'); + if (!parent) return; + + byltContainer = document.createElement('div'); + byltContainer.id = 'discover-bylt-sections'; + parent.parentNode.insertBefore(byltContainer, parent.nextSibling); + } + + if (!_byltCtrl) { + _byltCtrl = createDiscoverSectionController({ + id: 'because-you-listen-to', + contentEl: '#discover-bylt-sections', + fetchUrl: '/api/discover/because-you-listen-to', + extractItems: (data) => data.sections || [], + // No per-section empty/loading copy — when there's nothing + // to show we leave the container blank rather than render a + // placeholder, matching the original no-op behavior. + renderEmptyState: false, + loadingMessage: '', + renderItems: (items) => items.map((s, i) => _renderByltSection(s, i)).join(''), + onRendered: ({ items }) => { + // Inject track cards into each section's carousel after + // the section wrappers exist in the DOM. + items.forEach((section, idx) => { + const carousel = document.getElementById(`bylt-carousel-${idx}`); + if (!carousel) return; + carousel.innerHTML = section.tracks.map(t => _renderByltTrackCard(t)).join(''); + }); + }, + }); } + return _byltCtrl.load(); } // =============================== diff --git a/webui/static/helper.js b/webui/static/helper.js index d59543b9..6d9e9869 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3432,6 +3432,7 @@ const WHATS_NEW = { '2.4.3': [ // --- post-2.4.2 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- { date: 'Unreleased — 2.4.3 dev cycle' }, + { title: 'Internal: Migrate 7 More Discover Sections to the Controller', desc: 'follow-up to the foundation commit. migrated fresh tape, the archives, time machine intro carousel, browse by genre intro carousel, seasonal mix, your artists, and because-you-listen-to onto `createDiscoverSectionController`. each one drops its own hand-rolled try/catch + spinner injection + empty-state HTML + error swallow in favor of a config object — controller owns the lifecycle. net 76 lines smaller in discover.js even after adding the per-section render helpers. skipped two sections that don\'t fit the controller\'s single-fetch / single-render-target shape: `loadYourAlbums` (paginated grid + filters, four separate UI elements updated) and `loadSeasonalAlbums` (no fetch — receives pre-fetched data from parent). hidden / dead sections (~13 of them) untouched in this pass — separate audit commit will surface or kill them. controller extension candidates surfaced for follow-up: callable `fetchUrl` (so seasonal playlist doesn\'t need controller-recreate-on-key-change), explicit `isStale` / `onStale` hook (so your-artists doesn\'t fold stale handling into renderItems), `beforeLoad` hook (so because-you-listen-to can let the controller own the dynamic container creation), and a no-fetch `data:` mode (so render-only sections like seasonal albums can use the controller). zero behavior changes — every public load function keeps its name + signature so existing callers, refresh buttons, and dashboard wiring don\'t notice the swap.', page: 'discover' }, { title: 'Internal: Discover Section Controller Foundation', desc: 'every section on the discover page (recent releases, your artists, your albums, seasonal, fresh tape, the archives, etc) re-implements the same lifecycle by hand: show spinner → fetch endpoint → parse → either render or show empty state or show error → maybe wire post-render handlers → maybe expose refresh. ~30 sections, all subtly drifting — different empty messages, different error handling (some console.debug, some silently swallowed, some leave the spinner spinning forever), different sync-status icons, no consistent error toast. lifted that lifecycle into a shared `createDiscoverSectionController` (renderers stay per-section because section data shapes legitimately differ — album cards vs artist circles vs playlist tiles vs track rows; the controller is the wrapper, not a forced visual abstraction). this commit is the foundation: built the controller + migrated `recent releases` as proof. each remaining section will migrate in its own follow-up commit (keeps reviews small + lets us sequence the work). once everything is on the controller, the discover-page cleanup work (kill 13 dead sections, standardize sync-status icons, add error toasts) becomes single-line registry edits instead of section-by-section rewrites.', page: 'discover' }, ], '2.4.2': [