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 = '
';
-
- 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.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.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 = '';
- 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.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 `
+
+
+
+
${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 += `
-
-
-
-
${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 = '';
- }
}
+ 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 `
+
+
+
+
${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 += `
-
-
-
-
${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 = '';
- }
}
+ 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 = '';
-
- // 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 = '';
- 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.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 = '';
- }
+ _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) => `
-
-