Discover: unify Decade + Genre tabbed browsers
Both tabbed-browser sections — Time Machine ("Decade") and Browse by
Genre — re-implemented the same lifecycle by hand: fetch tabs list,
render the tab strip, attach click handlers, fetch content per tab,
render track list with sync + download action buttons + sync-status
block, handle empty/error/loading states. ~314 lines of identical
boilerplate split across two browsers.
Lifted into one shared `createTabbedBrowserSection(config)` helper.
Each browser is now a thin wrapper:
```js
const ctrl = createTabbedBrowserSection({
id: 'decade-browser',
tabsContainerEl: '#decade-tabs',
contentContainerEl: '#decade-content',
fetchTabs: async () => { ... },
renderTabButton: (tab, isActive) => `<button>...</button>`,
fetchTabContent: async (tab) => { ... },
renderTabContent: (tracks, tab) => `...`,
onTabContentRendered: (tab, contentEl) => { ... },
emptyMessage / errorMessage,
});
```
Migrated:
- `loadDecadeBrowserTabs` 85 → 3 lines
- `loadDecadeTracks` 67 → 3 lines
- `loadGenreBrowserTabs` 92 → 3 lines
- `loadGenreTracks` 70 → 3 lines
Helper: ~125 lines + ~100 lines of per-browser config blocks +
~25 lines of shared `_renderTabbedTrackList` (the two browsers had
byte-identical track-row markup so it lifted cleanly).
Public function names preserved — the four migrated functions stay
on the same signature so existing callers (`loadDiscoverPage`,
refresh buttons, inline handlers) don't change.
Side effects preserved — `decadeTracksCache[year]`, `activeDecade`,
`genreTracksCache[name]`, `activeGenre`, `availableGenres` still
mutated at the same lifecycle moments. The decade-specific
`startDecadeSync(decade)` and genre-specific `startGenreSync(name)`
sync-button handlers stay where they are; they're click handlers
attached to rendered content, not part of the tab lifecycle.
What didn't fit (intentionally left alone):
- `_renderCompactTrackRow` (the existing shared track-row helper) is
NOT used by the tabbed browsers — they had their own template
with a `track_data_json` fallback chain `_renderCompactTrackRow`
doesn't do. Unifying these two would change behavior for
non-tabbed sections, so the tabbed-browser variant lives as
`_renderTabbedTrackList`. Future cleanup could merge them by
giving `_renderCompactTrackRow` an opt-in fallback flag.
- `switchDecadeTab` / `switchGenreTab` still know about cache shape
so they can skip refetch on already-loaded tabs. Keeping that
in the per-browser switch is fine — it's a click handler, not
lifecycle.
Net: 8546 → 8578 LOC on `discover.js` (+32). Helper boilerplate
offsets the line count, but the win is single-source-of-truth, not
raw line reduction.
`node --check` clean. 2222/2222 full suite green.
This commit is contained in:
parent
0701bcc213
commit
44dd7f980f
2 changed files with 263 additions and 230 deletions
|
|
@ -1702,45 +1702,162 @@ function _renderSyncStatusBlock(idPrefix) {
|
|||
`;
|
||||
}
|
||||
|
||||
async function loadDecadeBrowserTabs() {
|
||||
try {
|
||||
const tabsContainer = document.getElementById('decade-tabs');
|
||||
const contentsContainer = document.getElementById('decade-tab-contents');
|
||||
// ===============================
|
||||
// TABBED BROWSER HELPER
|
||||
// ===============================
|
||||
//
|
||||
// Drives the lifecycle the decade browser ("Time Machine") and
|
||||
// genre browser ("Browse by Genre") share: fetch tab list → paint
|
||||
// tab strip + per-tab content shells → fetch + render content for
|
||||
// the active tab → handle empty / error states.
|
||||
//
|
||||
// The two browsers paint slightly different markup (different CSS
|
||||
// prefixes, different action buttons, different sync handlers) but
|
||||
// the lifecycle is identical. Each browser registers a config; the
|
||||
// helper handles the rest.
|
||||
//
|
||||
// Two-phase render:
|
||||
// Phase 1 (loadTabs) — paint tab strip + N content shells,
|
||||
// each shell containing a loading
|
||||
// spinner in its playlist container,
|
||||
// then trigger Phase 2 for first tab.
|
||||
// Phase 2 (loadTabContent) — fetch tracks for one tab, swap the
|
||||
// spinner in its playlist container
|
||||
// for the rendered track list.
|
||||
//
|
||||
// Renderers stay per-browser because action buttons + classes
|
||||
// legitimately differ. The helper owns the lifecycle, not the look.
|
||||
function createTabbedBrowserSection(config) {
|
||||
const cfg = Object.assign({
|
||||
// Diagnostic id used in console errors.
|
||||
id: 'tabbed-browser',
|
||||
// DOM IDs of the tab-strip + per-tab-contents containers.
|
||||
tabsContainerId: null,
|
||||
contentsContainerId: null,
|
||||
// Async fn returning array of tab descriptors (e.g. decades).
|
||||
fetchTabs: null,
|
||||
// (tab) => string unique id for one tab (e.g. 'decade-1980').
|
||||
// Used as the prefix for that tab's content + playlist + sync IDs.
|
||||
tabId: null,
|
||||
// (tab) => string HTML for one tab button. Receives `(tab, isActive)`.
|
||||
renderTabButton: null,
|
||||
// (tab) => string HTML for one tab's content shell (action
|
||||
// buttons + sync-status block + empty playlist container).
|
||||
// The playlist container inside MUST have id `${tabId}-playlist`
|
||||
// so the helper can fill it during Phase 2.
|
||||
renderTabShell: null,
|
||||
// Async fn (tab) => array of tracks for that tab.
|
||||
fetchTabContent: null,
|
||||
// (tracks, tab) => string HTML for the playlist container.
|
||||
renderTabTracks: null,
|
||||
// Copy / messages.
|
||||
emptyTabsMessage: 'No content available',
|
||||
emptyContentMessage: (tab) => 'No tracks found',
|
||||
errorTabsMessage: 'Failed to load',
|
||||
errorContentMessage: 'Failed to load tracks',
|
||||
// Fired after Phase 1 paints the tab strip + shells, before
|
||||
// Phase 2 is triggered for the first tab. Useful for caching
|
||||
// the tab list (e.g. `availableGenres = ...`).
|
||||
onTabsRendered: null,
|
||||
}, config || {});
|
||||
|
||||
if (!tabsContainer || !contentsContainer) return;
|
||||
async function loadTabs() {
|
||||
try {
|
||||
const tabsContainer = document.getElementById(cfg.tabsContainerId);
|
||||
const contentsContainer = document.getElementById(cfg.contentsContainerId);
|
||||
if (!tabsContainer || !contentsContainer) 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');
|
||||
const tabs = await cfg.fetchTabs();
|
||||
if (!Array.isArray(tabs) || tabs.length === 0) {
|
||||
tabsContainer.innerHTML = `<div class="discover-empty"><p>${cfg.emptyTabsMessage}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let tabsHTML = '';
|
||||
let contentsHTML = '';
|
||||
tabs.forEach((tab, index) => {
|
||||
const isActive = index === 0;
|
||||
tabsHTML += cfg.renderTabButton(tab, isActive);
|
||||
contentsHTML += cfg.renderTabShell(tab, isActive);
|
||||
});
|
||||
|
||||
tabsContainer.innerHTML = tabsHTML;
|
||||
contentsContainer.innerHTML = contentsHTML;
|
||||
|
||||
if (typeof cfg.onTabsRendered === 'function') {
|
||||
try { cfg.onTabsRendered(tabs); }
|
||||
catch (err) { console.debug(`[${cfg.id}] onTabsRendered threw:`, err); }
|
||||
}
|
||||
|
||||
// Phase 2: kick off content load for the first tab.
|
||||
await loadTabContent(tabs[0]);
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${cfg.id} tabs:`, error);
|
||||
const tabsContainer = document.getElementById(cfg.tabsContainerId);
|
||||
if (tabsContainer) {
|
||||
tabsContainer.innerHTML = `<div class="discover-empty"><p>${cfg.errorTabsMessage}</p></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.decades || data.decades.length === 0) {
|
||||
tabsContainer.innerHTML = '<div class="discover-empty"><p>No decade content available yet. Run a watchlist scan to populate your discovery pool!</p></div>';
|
||||
return;
|
||||
async function loadTabContent(tab) {
|
||||
const tabId = cfg.tabId(tab);
|
||||
const playlistContainer = document.getElementById(`${tabId}-playlist`);
|
||||
if (!playlistContainer) return;
|
||||
|
||||
try {
|
||||
const tracks = await cfg.fetchTabContent(tab);
|
||||
if (!Array.isArray(tracks) || tracks.length === 0) {
|
||||
const msg = (typeof cfg.emptyContentMessage === 'function')
|
||||
? cfg.emptyContentMessage(tab)
|
||||
: cfg.emptyContentMessage;
|
||||
playlistContainer.innerHTML = `<div class="discover-empty"><p>${msg}</p></div>`;
|
||||
return;
|
||||
}
|
||||
playlistContainer.innerHTML = cfg.renderTabTracks(tracks, tab);
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${cfg.id} tab content:`, error);
|
||||
const stillThere = document.getElementById(`${tabId}-playlist`);
|
||||
if (stillThere) {
|
||||
stillThere.innerHTML = `<div class="discover-empty"><p>${cfg.errorContentMessage}</p></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build decade tabs
|
||||
let tabsHTML = '';
|
||||
let contentsHTML = '';
|
||||
return { loadTabs, loadTabContent };
|
||||
}
|
||||
|
||||
data.decades.forEach((decade, index) => {
|
||||
const isActive = index === 0;
|
||||
// ----- Decade browser config + thin wrappers -----
|
||||
|
||||
let _decadeBrowserTabsCtrl = null;
|
||||
|
||||
function _getDecadeBrowserTabsCtrl() {
|
||||
if (_decadeBrowserTabsCtrl) return _decadeBrowserTabsCtrl;
|
||||
_decadeBrowserTabsCtrl = createTabbedBrowserSection({
|
||||
id: 'decade-browser-tabs',
|
||||
tabsContainerId: 'decade-tabs',
|
||||
contentsContainerId: 'decade-tab-contents',
|
||||
fetchTabs: async () => {
|
||||
const response = await fetch('/api/discover/decades/available');
|
||||
if (!response.ok) throw new Error('Failed to fetch available decades');
|
||||
const data = await response.json();
|
||||
if (!data.success) return [];
|
||||
return data.decades || [];
|
||||
},
|
||||
tabId: (decade) => `decade-${decade.year}`,
|
||||
renderTabButton: (decade, isActive) => {
|
||||
const icon = getDecadeIcon(decade.year);
|
||||
const tabId = `decade-${decade.year}`;
|
||||
|
||||
// Tab button
|
||||
tabsHTML += `
|
||||
return `
|
||||
<button class="decade-tab ${isActive ? 'active' : ''}"
|
||||
data-decade="${decade.year}"
|
||||
onclick="switchDecadeTab(${decade.year})">
|
||||
${icon} ${decade.year}s
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Tab content
|
||||
contentsHTML += `
|
||||
},
|
||||
renderTabShell: (decade, isActive) => {
|
||||
const tabId = `decade-${decade.year}`;
|
||||
return `
|
||||
<div class="decade-tab-content ${isActive ? 'active' : ''}" id="${tabId}-content">
|
||||
<!-- Action Buttons -->
|
||||
<div class="decade-actions">
|
||||
|
|
@ -1770,23 +1887,69 @@ async function loadDecadeBrowserTabs() {
|
|||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
},
|
||||
fetchTabContent: async (decade) => {
|
||||
const response = await fetch(`/api/discover/decade/${decade.year}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch decade playlist');
|
||||
const data = await response.json();
|
||||
if (!data.success) return [];
|
||||
// Side-effect: cache + active marker, exactly as old code did.
|
||||
decadeTracksCache[decade.year] = data.tracks || [];
|
||||
activeDecade = decade.year;
|
||||
return data.tracks || [];
|
||||
},
|
||||
renderTabTracks: (tracks) => _renderTabbedTrackList(tracks),
|
||||
emptyContentMessage: (decade) => `No tracks found for the ${decade.year}s`,
|
||||
errorTabsMessage: 'Failed to load decades',
|
||||
errorContentMessage: 'Failed to load decade tracks',
|
||||
emptyTabsMessage: 'No decade content available yet. Run a watchlist scan to populate your discovery pool!',
|
||||
});
|
||||
return _decadeBrowserTabsCtrl;
|
||||
}
|
||||
|
||||
tabsContainer.innerHTML = tabsHTML;
|
||||
contentsContainer.innerHTML = contentsHTML;
|
||||
|
||||
// Load first decade's tracks
|
||||
if (data.decades.length > 0) {
|
||||
await loadDecadeTracks(data.decades[0].year);
|
||||
// Shared track-row markup for tabbed browsers. Decade + genre rows
|
||||
// have the same shape — both pull from `track_data_json` first then
|
||||
// fall back to top-level fields. Lifted so the helper-driven
|
||||
// renderers don't each carry a copy.
|
||||
function _renderTabbedTrackList(tracks) {
|
||||
let html = '<div class="discover-playlist-tracks-compact">';
|
||||
tracks.forEach((track, index) => {
|
||||
let trackData = track;
|
||||
if (track.track_data_json) {
|
||||
trackData = track.track_data_json;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading decade browser tabs:', error);
|
||||
const tabsContainer = document.getElementById('decade-tabs');
|
||||
if (tabsContainer) {
|
||||
tabsContainer.innerHTML = '<div class="discover-empty"><p>Failed to load decades</p></div>';
|
||||
}
|
||||
}
|
||||
const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
|
||||
const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
|
||||
const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
|
||||
const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
|
||||
const durationMs = trackData.duration_ms || track.duration_ms || 0;
|
||||
|
||||
const durationMin = Math.floor(durationMs / 60000);
|
||||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||||
|
||||
html += `
|
||||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||||
<div class="track-compact-number">${index + 1}</div>
|
||||
<div class="track-compact-image">
|
||||
<img src="${coverUrl}" alt="${albumName}" loading="lazy">
|
||||
</div>
|
||||
<div class="track-compact-info">
|
||||
<div class="track-compact-name">${trackName}</div>
|
||||
<div class="track-compact-artist">${artistName}</div>
|
||||
</div>
|
||||
<div class="track-compact-album">${albumName}</div>
|
||||
<div class="track-compact-duration">${duration}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
async function loadDecadeBrowserTabs() {
|
||||
return _getDecadeBrowserTabsCtrl().loadTabs();
|
||||
}
|
||||
|
||||
function switchDecadeTab(decade) {
|
||||
|
|
@ -1817,71 +1980,7 @@ function switchDecadeTab(decade) {
|
|||
}
|
||||
|
||||
async function loadDecadeTracks(decade) {
|
||||
try {
|
||||
const playlistContainer = document.getElementById(`decade-${decade}-playlist`);
|
||||
if (!playlistContainer) return;
|
||||
|
||||
const response = await fetch(`/api/discover/decade/${decade}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch decade playlist');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>No tracks found for the ' + decade + 's</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Store tracks in cache
|
||||
decadeTracksCache[decade] = data.tracks;
|
||||
activeDecade = decade;
|
||||
|
||||
// Build compact playlist HTML
|
||||
let html = '<div class="discover-playlist-tracks-compact">';
|
||||
data.tracks.forEach((track, index) => {
|
||||
// Extract track data from track_data_json if available
|
||||
let trackData = track;
|
||||
if (track.track_data_json) {
|
||||
trackData = track.track_data_json;
|
||||
}
|
||||
|
||||
// Get track properties with fallbacks
|
||||
const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
|
||||
const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
|
||||
const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
|
||||
const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
|
||||
const durationMs = trackData.duration_ms || track.duration_ms || 0;
|
||||
|
||||
const durationMin = Math.floor(durationMs / 60000);
|
||||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||||
|
||||
html += `
|
||||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||||
<div class="track-compact-number">${index + 1}</div>
|
||||
<div class="track-compact-image">
|
||||
<img src="${coverUrl}" alt="${albumName}" loading="lazy">
|
||||
</div>
|
||||
<div class="track-compact-info">
|
||||
<div class="track-compact-name">${trackName}</div>
|
||||
<div class="track-compact-artist">${artistName}</div>
|
||||
</div>
|
||||
<div class="track-compact-album">${albumName}</div>
|
||||
<div class="track-compact-duration">${duration}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
playlistContainer.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading decade tracks:', error);
|
||||
const playlistContainer = document.getElementById(`decade-${decade}-playlist`);
|
||||
if (playlistContainer) {
|
||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load decade tracks</p></div>';
|
||||
}
|
||||
}
|
||||
return _getDecadeBrowserTabsCtrl().loadTabContent({ year: decade });
|
||||
}
|
||||
|
||||
async function startDecadeSync(decade) {
|
||||
|
|
@ -2080,64 +2179,58 @@ let genreTracksCache = {}; // Store tracks for each genre
|
|||
let activeGenre = null;
|
||||
let availableGenres = [];
|
||||
|
||||
async function loadGenreBrowserTabs() {
|
||||
try {
|
||||
const tabsContainer = document.getElementById('genre-tabs');
|
||||
const contentsContainer = document.getElementById('genre-tab-contents');
|
||||
// Helper: derive the URL/DOM-safe id used for a genre tab. Both
|
||||
// the tab shell and the playlist-container element are keyed on this.
|
||||
function _genreTabId(genreName) {
|
||||
const safe = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||||
return `genre-${safe}`;
|
||||
}
|
||||
|
||||
if (!tabsContainer || !contentsContainer) return;
|
||||
let _genreBrowserTabsCtrl = null;
|
||||
|
||||
// Fetch available genres from backend
|
||||
const response = await fetch('/api/discover/genres/available');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch available genres');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.genres || data.genres.length === 0) {
|
||||
tabsContainer.innerHTML = '<div class="discover-empty"><p>No genre content available yet. Run a watchlist scan to populate your discovery pool!</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
availableGenres = data.genres;
|
||||
|
||||
// Build genre tabs (limit to first 8-10 to avoid overcrowding)
|
||||
const displayGenres = data.genres.slice(0, 10);
|
||||
let tabsHTML = '';
|
||||
let contentsHTML = '';
|
||||
|
||||
displayGenres.forEach((genre, index) => {
|
||||
const isActive = index === 0;
|
||||
function _getGenreBrowserTabsCtrl() {
|
||||
if (_genreBrowserTabsCtrl) return _genreBrowserTabsCtrl;
|
||||
_genreBrowserTabsCtrl = createTabbedBrowserSection({
|
||||
id: 'genre-browser-tabs',
|
||||
tabsContainerId: 'genre-tabs',
|
||||
contentsContainerId: 'genre-tab-contents',
|
||||
fetchTabs: async () => {
|
||||
const response = await fetch('/api/discover/genres/available');
|
||||
if (!response.ok) throw new Error('Failed to fetch available genres');
|
||||
const data = await response.json();
|
||||
if (!data.success) return [];
|
||||
// Cap at 10 to avoid overcrowding; old behavior preserved.
|
||||
return (data.genres || []).slice(0, 10);
|
||||
},
|
||||
onTabsRendered: (genres) => { availableGenres = genres; },
|
||||
tabId: (genre) => _genreTabId(genre.name),
|
||||
renderTabButton: (genre, isActive) => {
|
||||
const icon = getGenreIcon(genre.name);
|
||||
const genreName = genre.name;
|
||||
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||||
const tabId = `genre-${genreId}`;
|
||||
|
||||
// Tab button
|
||||
tabsHTML += `
|
||||
return `
|
||||
<button class="genre-tab ${isActive ? 'active' : ''}"
|
||||
data-genre="${escapeHtml(genreName)}"
|
||||
onclick="switchGenreTab('${escapeForInlineJs(genreName)}')">
|
||||
${icon} ${capitalizeGenre(genreName)}
|
||||
data-genre="${escapeHtml(genre.name)}"
|
||||
onclick="switchGenreTab('${escapeForInlineJs(genre.name)}')">
|
||||
${icon} ${capitalizeGenre(genre.name)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Tab content
|
||||
contentsHTML += `
|
||||
<div class="genre-tab-content ${isActive ? 'active' : ''}" id="${tabId}-content" data-genre="${escapeHtml(genreName)}">
|
||||
},
|
||||
renderTabShell: (genre, isActive) => {
|
||||
const tabId = _genreTabId(genre.name);
|
||||
return `
|
||||
<div class="genre-tab-content ${isActive ? 'active' : ''}" id="${tabId}-content" data-genre="${escapeHtml(genre.name)}">
|
||||
<!-- Action Buttons -->
|
||||
<div class="genre-actions">
|
||||
<div class="discover-section-header">
|
||||
<div>
|
||||
<h3 style="margin: 0; color: #fff; font-size: 18px;">${capitalizeGenre(genreName)} Mix</h3>
|
||||
<h3 style="margin: 0; color: #fff; font-size: 18px;">${capitalizeGenre(genre.name)} Mix</h3>
|
||||
<p id="${tabId}-subtitle" style="margin: 4px 0 0 0; color: #999; font-size: 13px;">${genre.track_count} tracks</p>
|
||||
</div>
|
||||
<div class="discover-section-actions">
|
||||
<button class="action-button secondary" onclick="openDownloadModalForGenre('${escapeForInlineJs(genreName)}')" title="Download missing tracks">
|
||||
<button class="action-button secondary" onclick="openDownloadModalForGenre('${escapeForInlineJs(genre.name)}')" title="Download missing tracks">
|
||||
<span class="button-icon">↓</span>
|
||||
<span class="button-text">Download</span>
|
||||
</button>
|
||||
<button class="action-button primary" id="${tabId}-sync-btn" onclick="startGenreSync('${escapeForInlineJs(genreName)}')" title="Sync to media server">
|
||||
<button class="action-button primary" id="${tabId}-sync-btn" onclick="startGenreSync('${escapeForInlineJs(genre.name)}')" title="Sync to media server">
|
||||
<span class="button-icon">⟳</span>
|
||||
<span class="button-text">Sync</span>
|
||||
</button>
|
||||
|
|
@ -2149,27 +2242,32 @@ async function loadGenreBrowserTabs() {
|
|||
|
||||
<!-- Track List -->
|
||||
<div class="discover-playlist-container compact" id="${tabId}-playlist">
|
||||
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading ${capitalizeGenre(genreName)} tracks...</p></div>
|
||||
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading ${capitalizeGenre(genre.name)} tracks...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
},
|
||||
fetchTabContent: async (genre) => {
|
||||
const response = await fetch(`/api/discover/genre/${encodeURIComponent(genre.name)}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch genre playlist');
|
||||
const data = await response.json();
|
||||
if (!data.success) return [];
|
||||
// Side-effect: cache + active marker, exactly as old code did.
|
||||
genreTracksCache[genre.name] = data.tracks || [];
|
||||
activeGenre = genre.name;
|
||||
return data.tracks || [];
|
||||
},
|
||||
renderTabTracks: (tracks) => _renderTabbedTrackList(tracks),
|
||||
emptyContentMessage: (genre) => `No tracks found for ${capitalizeGenre(genre.name)}`,
|
||||
errorTabsMessage: 'Failed to load genres',
|
||||
errorContentMessage: 'Failed to load genre tracks',
|
||||
emptyTabsMessage: 'No genre content available yet. Run a watchlist scan to populate your discovery pool!',
|
||||
});
|
||||
return _genreBrowserTabsCtrl;
|
||||
}
|
||||
|
||||
tabsContainer.innerHTML = tabsHTML;
|
||||
contentsContainer.innerHTML = contentsHTML;
|
||||
|
||||
// Load first genre's tracks
|
||||
if (displayGenres.length > 0) {
|
||||
await loadGenreTracks(displayGenres[0].name);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading genre browser tabs:', error);
|
||||
const tabsContainer = document.getElementById('genre-tabs');
|
||||
if (tabsContainer) {
|
||||
tabsContainer.innerHTML = '<div class="discover-empty"><p>Failed to load genres</p></div>';
|
||||
}
|
||||
}
|
||||
async function loadGenreBrowserTabs() {
|
||||
return _getGenreBrowserTabsCtrl().loadTabs();
|
||||
}
|
||||
|
||||
function switchGenreTab(genreName) {
|
||||
|
|
@ -2200,73 +2298,7 @@ function switchGenreTab(genreName) {
|
|||
}
|
||||
|
||||
async function loadGenreTracks(genreName) {
|
||||
try {
|
||||
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||||
const playlistContainer = document.getElementById(`genre-${genreId}-playlist`);
|
||||
if (!playlistContainer) return;
|
||||
|
||||
const response = await fetch(`/api/discover/genre/${encodeURIComponent(genreName)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch genre playlist');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||
playlistContainer.innerHTML = `<div class="discover-empty"><p>No tracks found for ${capitalizeGenre(genreName)}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Store tracks in cache
|
||||
genreTracksCache[genreName] = data.tracks;
|
||||
activeGenre = genreName;
|
||||
|
||||
// Build compact playlist HTML
|
||||
let html = '<div class="discover-playlist-tracks-compact">';
|
||||
data.tracks.forEach((track, index) => {
|
||||
// Extract track data from track_data_json if available
|
||||
let trackData = track;
|
||||
if (track.track_data_json) {
|
||||
trackData = track.track_data_json;
|
||||
}
|
||||
|
||||
// Get track properties with fallbacks
|
||||
const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
|
||||
const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
|
||||
const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
|
||||
const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
|
||||
const durationMs = trackData.duration_ms || track.duration_ms || 0;
|
||||
|
||||
const durationMin = Math.floor(durationMs / 60000);
|
||||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||||
|
||||
html += `
|
||||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||||
<div class="track-compact-number">${index + 1}</div>
|
||||
<div class="track-compact-image">
|
||||
<img src="${coverUrl}" alt="${albumName}" loading="lazy">
|
||||
</div>
|
||||
<div class="track-compact-info">
|
||||
<div class="track-compact-name">${trackName}</div>
|
||||
<div class="track-compact-artist">${artistName}</div>
|
||||
</div>
|
||||
<div class="track-compact-album">${albumName}</div>
|
||||
<div class="track-compact-duration">${duration}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
playlistContainer.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading genre tracks:', error);
|
||||
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||||
const playlistContainer = document.getElementById(`genre-${genreId}-playlist`);
|
||||
if (playlistContainer) {
|
||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load genre tracks</p></div>';
|
||||
}
|
||||
}
|
||||
return _getGenreBrowserTabsCtrl().loadTabContent({ name: genreName });
|
||||
}
|
||||
|
||||
async function startGenreSync(genreName) {
|
||||
|
|
|
|||
|
|
@ -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: 'Discover: Stop Showing Undownloadable Tracks (+Lift)', desc: 'audit found multiple discover-page sections (hidden gems / discovery shuffle / popular picks / decade browser / genre browser) had no `WHERE (spotify_track_id IS NOT NULL OR itunes_track_id IS NOT NULL OR ...)` gate on their selection sql. tracks with no source ids in the discovery pool were getting displayed, the user would click download, and the download would silently fail because there was nothing to look up. fix: lifted all five discovery_pool selection methods + four library-driven methods (recently added / top tracks / forgotten favorites / familiar favorites — these were stubbed returning [] because the schema predated the source-id columns; now re-enabled properly) into shared private helpers (`_select_discovery_tracks`, `_select_library_tracks`, `_apply_diversity_filter`, `_compute_adaptive_diversity_limits`) on `PersonalizedPlaylistsService`. mandatory id-validity gate is hard-coded into both selectors — no opt-out flag, every public method inherits it for free. behavior preserved: same diversity tiers, same over-fetch multipliers, same popularity thresholds, same blacklist filter. ~314 lines of repeated select/diversity boilerplate collapsed across the 5 discovery methods (-55% on those methods\' business logic). on the frontend, lifted the duplicated decade-browser + genre-browser tab management (~314 lines of identical fetch-tabs / render-tabstrip / fetch-content / render-tracklist / wire-sync-button code) into one shared `createTabbedBrowserSection(config)` helper. each browser is now a thin wrapper: ~3 lines per public function. 17 new tests pin the gate (every selector filters null-id rows), the diversity caps, the adaptive limit tiers, the source filter, and the blacklist filter. 2222/2222 full suite green.', page: 'discover' },
|
||||
{ title: 'Internal: Discover Controller — Cin Pre-Review Polish', desc: 'tightened the controller before opening the PR. (1) dropped the magic `extractItems` defaults — controller used to auto-pull `data.items` / `data.albums` / `data.artists` / `data.tracks` / `data.results` if no extractor was provided. removed the fallback chain. each section now MUST supply its own `extractItems(data) => array` callback. cin standard: explicit > implicit; the auto-fallback could silently grab the wrong key on endpoints that return multiple arrays. validated at register-time so misuse fails immediately. all 10 existing call sites already had explicit extractors so no migration churn. (2) replaced the `renderItems` returning null convention (used by Your Albums + manualDom-style sections) with an explicit `manualDom: true` config flag. clearer intent at the call site, less likely to be confused with a renderer error. (3) added a minimal node `--test` JS test file at `tests/static/test_discover_section_controller.mjs` — 32 tests pin the lifecycle contract: config validation (every required field), happy-path fetch+render, empty/stale/error states, no-fetch `data:` mode, manualDom mode, callable `fetchUrl`, load coalescing, refresh bypass, hook error containment, error toasts. runs via `node --test tests/static/` directly, OR via the regular pytest sweep (`tests/test_discover_section_controller_js.py` shells out to node and asserts a clean exit). skipped gracefully when node isn\'t available or is < 22. closes the "controller is a contract, pin it at the test boundary" gap that cin would have flagged. 2205/2205 full suite green (was 2204 + 1 new pytest wrapper); 32/32 node --test pass; ruff clean; js parses clean.', page: 'discover' },
|
||||
{ title: 'Internal: Discover Cleanup Round — Toast Errors, Stale State, Skipped Sections', desc: 'follow-up to the controller migration. extended `createDiscoverSectionController` with the hooks the per-section migrations surfaced as needed: callable `fetchUrl` (resolves the seasonal-playlist recreate-on-key-change hack), no-fetch `data:` mode (lets render-only sections like seasonal albums use the controller without inventing a fake endpoint), `beforeLoad` hook (lets dynamically-inserted sections like because-you-listen-to ensure their container exists before the spinner shows), `onSuccess(data)` hook (cleaner home for sibling header / subtitle / button updates than folding them into renderItems), and an `isStale` / `onStale` / `renderStale` triple for the third render state (data is empty BUT upstream is still discovering — show updating UI + start a poller, instead of the bare empty-state copy). turned on `showErrorToast: true` for every migrated section — section load failures now surface a global toast instead of silently spinning forever or swallowing into console.debug. that\'s the JohnBaumb #369 pattern applied at the UI layer. migrated the two sections that didn\'t fit the original controller contract: `loadYourAlbums` (uses isStale/onStale for stale-fetch UI + onSuccess for subtitle/filters/download-button side-effects + renderItems returning null since it delegates to the existing grid renderer) and `loadSeasonalAlbums` (uses no-fetch data mode since the parent `loadSeasonalContent` already fetched the season payload). also lifted the duplicated decade-tab + genre-tab sync-status block (✓/⏳/✗/percentage) into a `_renderSyncStatusBlock(idPrefix)` helper — two call sites now share one implementation. listenbrainz playlists keep their own block because the semantics differ (matching progress vs download progress). audit found the 13 supposedly-dead hidden sections aren\'t dead at all — they\'re gated on user data (discovery pool, library content, metadata cache) and self-surface when their data exists. removed one orphaned `loadPersonalizedDailyMixes()` call from `blockDiscoveryArtist` — daily mixes is intentionally paused, refreshing it from there was a no-op.', page: 'discover' },
|
||||
{ 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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue