';
}
if (bgEl && artist.image_url) {
bgEl.style.backgroundImage = `url('${artist.image_url}')`;
bgEl.style.backgroundSize = 'cover';
bgEl.style.backgroundPosition = 'center';
}
// Store artist ID for both buttons and update watchlist state
// Use artist_id which is set by the backend to the appropriate ID for the active source
const addBtn = document.getElementById('discover-hero-add');
const discographyBtn = document.getElementById('discover-hero-discography');
const artistId = artist.artist_id || artist.spotify_artist_id || artist.itunes_artist_id;
if (addBtn && artistId) {
addBtn.setAttribute('data-artist-id', artistId);
addBtn.setAttribute('data-artist-name', artist.artist_name);
// Also store both IDs for cross-source operations
if (artist.spotify_artist_id) addBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
if (artist.itunes_artist_id) addBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
// Check if this artist is already in watchlist and update button appearance
checkAndUpdateDiscoverHeroWatchlistButton(artistId);
}
if (discographyBtn && artistId) {
discographyBtn.setAttribute('data-artist-id', artistId);
discographyBtn.setAttribute('data-artist-name', artist.artist_name);
// Source the click handler will pass to navigateToArtistDetail. Without
// this, source-only hero artists (which is the typical case โ they
// come from discover similar-artists, not the library) get looked up
// as library IDs and 404. Backend always includes artist.source.
if (artist.source) discographyBtn.setAttribute('data-source', artist.source);
else discographyBtn.removeAttribute('data-source');
// Also store both IDs for cross-source operations
if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
if (artist.itunes_artist_id) discographyBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
}
// Update slideshow indicators
updateDiscoverHeroIndicators();
}
async function checkAndUpdateDiscoverHeroWatchlistButton(artistId) {
try {
const response = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (!data.success) return;
const addBtn = document.getElementById('discover-hero-add');
if (!addBtn) return;
const icon = addBtn.querySelector('.watchlist-icon');
const text = addBtn.querySelector('.watchlist-text');
if (data.is_watching) {
// Artist is in watchlist
if (icon) icon.textContent = '๐๏ธ';
if (text) text.textContent = 'Watching...';
addBtn.classList.add('watching');
} else {
// Artist not in watchlist
if (icon) icon.textContent = '๐๏ธ';
if (text) text.textContent = 'Add to Watchlist';
addBtn.classList.remove('watching');
}
} catch (error) {
console.error('Error checking watchlist status for hero:', error);
}
}
function toggleDiscoverHeroWatchlist(event) {
event.stopPropagation();
const button = document.getElementById('discover-hero-add');
if (!button) return;
const artistId = button.getAttribute('data-artist-id');
const artistName = button.getAttribute('data-artist-name');
if (!artistId || !artistName) {
console.error('No artist data found on discover hero button');
return;
}
// Call the existing toggleWatchlist function
toggleWatchlist(event, artistId, artistName);
}
async function watchAllHeroArtists(btn) {
if (!discoverHeroArtists || discoverHeroArtists.length === 0) return;
if (btn.classList.contains('all-watched')) return;
const textEl = btn.querySelector('.watch-all-text');
const originalText = textEl ? textEl.textContent : '';
// Loading state
btn.disabled = true;
if (textEl) textEl.textContent = 'Adding...';
try {
const artists = discoverHeroArtists.map(a => ({
artist_id: a.artist_id,
artist_name: a.artist_name
}));
const response = await fetch('/api/watchlist/add-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists })
});
const data = await response.json();
if (data.success) {
if (textEl) textEl.textContent = 'All Watched';
btn.classList.add('all-watched');
btn.disabled = true;
// Sync the per-slide watchlist button for current artist
const currentArtist = discoverHeroArtists[discoverHeroIndex];
if (currentArtist) {
checkAndUpdateDiscoverHeroWatchlistButton(currentArtist.artist_id);
}
// Update watchlist count badge
if (typeof updateWatchlistButtonCount === 'function') {
updateWatchlistButtonCount();
}
} else {
if (textEl) textEl.textContent = originalText;
btn.disabled = false;
}
} catch (error) {
console.error('Error watching all hero artists:', error);
if (textEl) textEl.textContent = originalText;
btn.disabled = false;
}
}
// Cache for recommended artists data so reopening is instant
let _recommendedArtistsCache = null;
async function openRecommendedArtistsModal() {
let modal = document.getElementById('recommended-artists-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'recommended-artists-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
modal.addEventListener('click', function (e) {
if (e.target === modal) closeRecommendedArtistsModal();
});
}
// If cached, render instantly and refresh watchlist statuses
if (_recommendedArtistsCache) {
modal.style.display = 'flex';
renderRecommendedArtistsModal(modal, _recommendedArtistsCache);
checkRecommendedWatchlistStatuses(_recommendedArtistsCache);
return;
}
// Show loading
modal.innerHTML = `
Recommended Artists
Loading...
×
Loading recommended artists...
`;
modal.style.display = 'flex';
try {
// Phase 1: Fetch basic data (instant โ no API enrichment)
const response = await fetch('/api/discover/similar-artists');
const data = await response.json();
if (!data.success || !data.artists || data.artists.length === 0) {
modal.querySelector('.playlist-modal-body').innerHTML = `
No recommended artists yet.
Run a watchlist scan to generate recommendations.
`;
modal.querySelector('.playlist-track-count').textContent = '0 artists';
return;
}
// Render cards immediately with fallback images
_recommendedArtistsCache = data.artists;
renderRecommendedArtistsModal(modal, data.artists);
// Phase 2: Enrich with images/genres progressively in batches of 50
// Skip artists that already have cached metadata from the initial response
const source = data.source || 'spotify';
const idKey = source === 'spotify' ? 'spotify_artist_id' : source === 'deezer' ? 'deezer_artist_id' : 'itunes_artist_id';
const allIds = data.artists
.filter(a => !a.image_url) // Only enrich artists without cached images
.map(a => a[idKey]).filter(Boolean);
for (let i = 0; i < allIds.length; i += 50) {
const batchIds = allIds.slice(i, i + 50);
try {
const enrichResp = await fetch('/api/discover/similar-artists/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: batchIds, source })
});
const enrichData = await enrichResp.json();
if (enrichData.success && enrichData.artists) {
// Update cards and cache as each batch arrives
for (const [aid, info] of Object.entries(enrichData.artists)) {
// Update the card in DOM
const card = modal.querySelector(`.recommended-artist-card[data-artist-id="${aid}"]`);
if (card && info.image_url) {
const imgContainer = card.querySelector('.recommended-card-image');
if (imgContainer) {
imgContainer.innerHTML = ``;
}
}
if (card && info.genres && info.genres.length > 0) {
const genresContainer = card.querySelector('.recommended-card-genres');
if (genresContainer) {
genresContainer.innerHTML = info.genres.map(g =>
`${escapeHtml(g)}`
).join('');
} else {
const infoDiv = card.querySelector('.recommended-card-info');
if (infoDiv) {
const genreDiv = document.createElement('div');
genreDiv.className = 'recommended-card-genres';
genreDiv.innerHTML = info.genres.map(g =>
`${escapeHtml(g)}`
).join('');
infoDiv.appendChild(genreDiv);
}
}
}
// Update cache
const cached = _recommendedArtistsCache.find(a => a.artist_id === aid || a.spotify_artist_id === aid || a.itunes_artist_id === aid);
if (cached) {
if (info.image_url) cached.image_url = info.image_url;
if (info.genres) cached.genres = info.genres;
if (info.artist_name) cached.artist_name = info.artist_name;
}
}
}
} catch (enrichErr) {
console.error('Error enriching batch:', enrichErr);
}
}
// Phase 3: Check watchlist statuses
checkRecommendedWatchlistStatuses(data.artists);
} catch (error) {
console.error('Error loading recommended artists:', error);
modal.querySelector('.playlist-modal-body').innerHTML = `
Error loading recommended artists.
`;
}
}
function renderRecommendedArtistsModal(modal, artists) {
modal.innerHTML = `
${artists.map(artist => {
const genreTags = (artist.genres || []).slice(0, 3).map(g =>
`${escapeHtml(g)}`
).join('');
const similarText = artist.occurrence_count > 1
? `Similar to ${artist.occurrence_count} in your watchlist`
: 'Similar to an artist in your watchlist';
return `
${artist.image_url ? `
` : `
๐ค
`}
${escapeHtml(artist.artist_name)}${similarText}
${genreTags}
`;
}).join('')}
`;
// Event delegation for card clicks and watchlist buttons
const grid = modal.querySelector('#recommended-artists-grid');
if (grid) {
grid.addEventListener('click', function (e) {
const watchlistBtn = e.target.closest('.recommended-card-watchlist-btn');
if (watchlistBtn) {
e.stopPropagation();
toggleRecommendedWatchlist(watchlistBtn);
return;
}
const card = e.target.closest('.recommended-artist-card');
if (card) {
const artistId = card.getAttribute('data-artist-id');
const nameEl = card.querySelector('.recommended-card-name');
const artistName = nameEl ? nameEl.textContent : '';
viewRecommendedArtistDiscography(artistId, artistName);
}
});
}
}
async function addAllRecommendedToWatchlist(btn) {
if (!_recommendedArtistsCache || _recommendedArtistsCache.length === 0) return;
if (btn.classList.contains('all-added')) return;
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Adding...';
try {
const artists = _recommendedArtistsCache.map(a => ({
artist_id: a.artist_id,
artist_name: a.artist_name
}));
const resp = await fetch('/api/watchlist/add-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists })
});
const data = await resp.json();
if (data.success) {
btn.textContent = `All Added (${data.added} new)`;
btn.classList.add('all-added');
btn.disabled = true;
// Update all watchlist buttons in the modal to "Watching"
document.querySelectorAll('.recommended-card-watchlist-btn').forEach(wBtn => {
wBtn.classList.add('watching');
wBtn.textContent = 'Watching';
});
if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount();
} else {
btn.textContent = originalText;
btn.disabled = false;
}
} catch (error) {
console.error('Error adding all recommended to watchlist:', error);
btn.textContent = originalText;
btn.disabled = false;
}
}
function closeRecommendedArtistsModal() {
const modal = document.getElementById('recommended-artists-modal');
if (modal) modal.style.display = 'none';
}
function filterRecommendedArtists() {
const query = (document.getElementById('recommended-search-input')?.value || '').toLowerCase();
const cards = document.querySelectorAll('.recommended-artist-card');
cards.forEach(card => {
const name = card.getAttribute('data-artist-name') || '';
card.style.display = name.includes(query) ? '' : 'none';
});
}
async function toggleRecommendedWatchlist(btn) {
const artistId = btn.getAttribute('data-artist-id');
const artistName = btn.getAttribute('data-artist-name');
if (!artistId || !artistName) return;
btn.disabled = true;
const wasWatching = btn.classList.contains('watching');
try {
if (wasWatching) {
const resp = await fetch('/api/watchlist/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await resp.json();
if (data.success) {
btn.classList.remove('watching');
btn.textContent = 'Add to Watchlist';
}
} else {
const resp = await fetch('/api/watchlist/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId, artist_name: artistName })
});
const data = await resp.json();
if (data.success) {
btn.classList.add('watching');
btn.textContent = 'Watching';
}
}
if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount();
} catch (error) {
console.error('Error toggling recommended watchlist:', error);
} finally {
btn.disabled = false;
}
}
async function checkRecommendedWatchlistStatuses(artists) {
try {
const artistIds = artists.map(a => a.artist_id).filter(Boolean);
if (!artistIds.length) return;
const resp = await fetch('/api/watchlist/check-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: artistIds })
});
const data = await resp.json();
if (data.success && data.results) {
for (const [aid, isWatching] of Object.entries(data.results)) {
if (isWatching) {
const btn = document.querySelector(`.recommended-card-watchlist-btn[data-artist-id="${aid}"]`);
if (btn) {
btn.classList.add('watching');
btn.textContent = 'Watching';
}
}
}
}
} catch (e) {
// Non-critical
}
}
async function viewRecommendedArtistDiscography(artistId, artistName) {
closeRecommendedArtistsModal();
navigateToArtistDetail(artistId, artistName);
}
async function checkAllHeroWatchlistStatus() {
const btn = document.getElementById('discover-hero-watch-all');
if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return;
try {
let allWatched = true;
for (const artist of discoverHeroArtists) {
const response = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artist.artist_id })
});
const data = await response.json();
if (!data.success || !data.is_watching) {
allWatched = false;
break;
}
}
const textEl = btn.querySelector('.watch-all-text');
if (allWatched) {
if (textEl) textEl.textContent = 'All Watched';
btn.classList.add('all-watched');
btn.disabled = true;
} else {
if (textEl) textEl.textContent = 'Watch All';
btn.classList.remove('all-watched');
btn.disabled = false;
}
} catch (error) {
console.error('Error checking hero watchlist status:', error);
}
}
function navigateDiscoverHero(direction) {
if (!discoverHeroArtists || discoverHeroArtists.length === 0) return;
// Update index with wrapping
discoverHeroIndex = (discoverHeroIndex + direction + discoverHeroArtists.length) % discoverHeroArtists.length;
// Display the artist
displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]);
// Update indicators
updateDiscoverHeroIndicators();
}
function updateDiscoverHeroIndicators() {
const indicatorsContainer = document.getElementById('discover-hero-indicators');
if (!indicatorsContainer || !discoverHeroArtists || discoverHeroArtists.length === 0) return;
// Create indicator dots
indicatorsContainer.innerHTML = discoverHeroArtists.map((_, index) => `
`).join('');
}
function jumpToDiscoverHeroSlide(index) {
if (!discoverHeroArtists || index < 0 || index >= discoverHeroArtists.length) return;
discoverHeroIndex = index;
displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]);
updateDiscoverHeroIndicators();
}
async function viewDiscoverHeroDiscography() {
const button = document.getElementById('discover-hero-discography');
if (!button) return;
const artistId = button.getAttribute('data-artist-id');
const artistName = button.getAttribute('data-artist-name');
// Pass the source so /api/artist-detail knows to synthesize from that
// metadata provider instead of doing a local DB lookup. Hero similar
// artists are almost always source-only (not in the library).
const source = button.getAttribute('data-source') || null;
if (!artistId || !artistName) {
console.error('No artist data found for discography view');
return;
}
console.log(`๐ต Navigating to artist detail for: ${artistName} (source: ${source || 'library'})`);
navigateToArtistDetail(artistId, artistName, source);
}
function showDiscoverHeroEmpty() {
const titleEl = document.getElementById('discover-hero-title');
const subtitleEl = document.getElementById('discover-hero-subtitle');
if (titleEl) titleEl.textContent = 'No Recommendations Yet';
if (subtitleEl) subtitleEl.textContent = 'Run a watchlist scan to generate personalized recommendations';
}
// Recent Releases โ first section migrated to the shared
// `createDiscoverSectionController`. The controller owns the
// loading / empty / error / refresh lifecycle that every other
// discover section currently re-implements by hand. This function
// stays as the public entry-point so existing callers don't change;
// internally it builds (or reuses) the controller and triggers a
// load. See `discover-section-controller.js` for the contract.
let _recentReleasesCtrl = null;
function _renderRecentReleaseCard(album, index) {
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
return `
${album.album_name}
${album.artist_name}
${album.release_date}
`;
}
async function loadDiscoverRecentReleases() {
if (!_recentReleasesCtrl) {
_recentReleasesCtrl = createDiscoverSectionController({
id: 'recent-releases',
contentEl: '#recent-releases-carousel',
fetchUrl: '/api/discover/recent-releases',
extractItems: (data) => data.albums || [],
renderItems: (items) => {
// Module-level `discoverRecentAlbums` is what the click
// handler reads to look up the album by index. Keep it
// in sync so `openDownloadModalForRecentAlbum(index)`
// still resolves correctly after re-renders.
discoverRecentAlbums = items;
return items.map((album, i) => _renderRecentReleaseCard(album, i)).join('');
},
loadingMessage: 'Loading recent releases...',
emptyMessage: 'No recent releases found',
errorMessage: 'Failed to load recent releases',
showErrorToast: true,
});
}
return _recentReleasesCtrl.load();
}
// ===============================
// ===============================
// YOUR ALBUMS SECTION
// ===============================
let yourAlbums = [];
let yourAlbumsPage = 1;
let yourAlbumsTotal = 0;
const YOUR_ALBUMS_PAGE_SIZE = 48;
let _yourAlbumsSearchTimeout = null;
function debouncedYourAlbumsSearch() {
clearTimeout(_yourAlbumsSearchTimeout);
_yourAlbumsSearchTimeout = setTimeout(() => {
yourAlbumsPage = 1;
loadYourAlbumsGrid();
}, 400);
}
let _yourAlbumsCtrl = null;
async function loadYourAlbums() {
if (!_yourAlbumsCtrl) {
_yourAlbumsCtrl = createDiscoverSectionController({
id: 'your-albums',
sectionEl: '#your-albums-section',
contentEl: '#your-albums-grid',
fetchUrl: '/api/discover/your-albums?page=1&per_page=48&status=all',
extractItems: (data) => data.albums || [],
// Truly empty (no data + not stale) \u2192 hide the whole section
// (matches the legacy "Nothing to show yet" early-return). The
// outer hideWhenEmpty + sectionEl handle the visibility flip.
isEmpty: (items, data) => {
const total = (data && data.stats && data.stats.total) || 0;
return total === 0 && !data.stale;
},
hideWhenEmpty: true,
// Stale + no albums yet \u2192 show the "fetching from connected
// services" UI and start the poller. Fires before isEmpty.
isStale: (items, data) => {
const total = (data && data.stats && data.stats.total) || 0;
return Boolean(data && data.stale) && total === 0;
},
renderStale: () =>
'
Fetching your albums from connected services...
',
onStale: () => _pollYourAlbums(),
// Side-effects against sibling DOM (subtitle / filters /
// download button) belong here, not in renderItems.
onSuccess: (data) => {
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
const totalCount = (data.stats && data.stats.total) || 0;
const filters = document.getElementById('your-albums-filters');
if (filters && totalCount > 0) filters.style.display = '';
const downloadBtn = document.getElementById('your-albums-download-btn');
if (downloadBtn && data.stats && data.stats.missing > 0) downloadBtn.style.display = '';
},
// Renderer delegates to the existing grid renderer, which
// writes its own DOM into `#your-albums-grid`. `manualDom`
// tells the controller not to clobber it.
manualDom: true,
renderItems: (items, data) => {
yourAlbums = items;
yourAlbumsTotal = data.total || 0;
yourAlbumsPage = 1;
_renderYourAlbumsGrid(yourAlbums);
_renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
},
errorMessage: 'Failed to load your albums',
verboseErrors: true,
showErrorToast: true,
});
}
return _yourAlbumsCtrl.load();
}
function _pollYourAlbums() {
let attempts = 0;
const poll = setInterval(async () => {
attempts++;
if (attempts > 12) { clearInterval(poll); return; }
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all');
if (!resp.ok) return;
const data = await resp.json();
if (!data.success) return;
const total = (data.stats && data.stats.total) || 0;
if (total > 0) {
clearInterval(poll);
loadYourAlbums();
}
} catch (e) { }
}, 5000);
}
async function loadYourAlbumsGrid() {
const grid = document.getElementById('your-albums-grid');
if (!grid) return;
grid.innerHTML = '
Loading...
';
try {
const search = (document.getElementById('your-albums-search')?.value || '').trim();
const status = document.getElementById('your-albums-status-filter')?.value || 'all';
const sort = document.getElementById('your-albums-sort')?.value || 'artist_name';
const params = new URLSearchParams({ page: yourAlbumsPage, per_page: YOUR_ALBUMS_PAGE_SIZE, sort, status });
if (search) params.set('search', search);
const resp = await fetch(`/api/discover/your-albums?${params}`);
const data = await resp.json();
if (!data.success) throw new Error(data.error);
yourAlbums = data.albums || [];
yourAlbumsTotal = data.total || 0;
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
_renderYourAlbumsGrid(yourAlbums);
_renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
} catch (e) {
console.error('Error loading your albums grid:', e);
grid.innerHTML = '
Failed to load albums
';
}
}
function _renderYourAlbumsGrid(albums) {
const grid = document.getElementById('your-albums-grid');
if (!grid) return;
if (!albums || albums.length === 0) {
grid.innerHTML = '
`;
},
loadingMessage: 'Curating your discovery playlist...',
emptyMessage: 'No tracks available yet',
errorMessage: 'Failed to load discovery weekly',
verboseErrors: true,
showErrorToast: true,
});
}
return _weeklyCtrl.load();
}
// ===============================
// DECADE BROWSER
// ===============================
let selectedDecade = null;
let decadeTracks = [];
function _renderDecadeCard(decade) {
const icon = getDecadeIcon(decade.year);
const label = `${decade.year}s`;
return `
${icon}
${label}
${decade.track_count} tracks
Classics
`;
}
let _decadeBrowserCtrl = null;
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,
showErrorToast: true,
});
}
return _decadeBrowserCtrl.load();
}
function getDecadeIcon(year) {
const icons = {
1950: '๐บ',
1960: '๐ธ',
1970: '๐บ',
1980: '๐ป',
1990: '๐ฟ',
2000: '๐ฑ',
2010: '๐ง',
2020: '๐'
};
return icons[year] || '๐ต';
}
async function openDecadePlaylist(decade) {
try {
showLoadingOverlay(`Loading ${decade}s playlist...`);
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) {
const message = data.message || `No tracks found for the ${decade}s`;
showToast(message, 'info');
hideLoadingOverlay();
return;
}
selectedDecade = decade;
decadeTracks = data.tracks;
// Open download modal
const playlistName = `${decade}s Classics`;
const virtualPlaylistId = `decade_${decade}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks);
hideLoadingOverlay();
} catch (error) {
console.error(`Error opening ${decade}s playlist:`, error);
showToast(`Failed to load ${decade}s playlist`, 'error');
hideLoadingOverlay();
}
}
// ===============================
// GENRE BROWSER
// ===============================
let selectedGenre = null;
let genreTracks = [];
function _renderGenreCard(genre) {
const icon = getGenreIcon(genre.name);
const displayName = capitalizeGenre(genre.name);
return `
${icon}
${displayName}
${genre.track_count} tracks
Curated
`;
}
let _genreBrowserCtrl = null;
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,
showErrorToast: true,
});
}
return _genreBrowserCtrl.load();
}
function getGenreIcon(genreName) {
const genre = genreName.toLowerCase();
// Parent genre exact matches (consolidated categories)
if (genre === 'electronic/dance') return '๐น';
if (genre === 'hip hop/rap') return '๐ค';
if (genre === 'rock') return '๐ธ';
if (genre === 'pop') return '๐ต';
if (genre === 'r&b/soul') return '๐๏ธ';
if (genre === 'jazz') return '๐บ';
if (genre === 'classical') return '๐ป';
if (genre === 'metal') return '๐ค';
if (genre === 'country') return '๐ช';
if (genre === 'folk/indie') return '๐ง';
if (genre === 'latin') return '๐';
if (genre === 'reggae/dancehall') return '๐ด';
if (genre === 'world') return '๐';
if (genre === 'alternative') return '๐ญ';
if (genre === 'blues') return '๐ธ';
if (genre === 'funk/disco') return '๐บ';
// Fallback: partial matching for specific genres
if (genre.includes('house') || genre.includes('techno') || genre.includes('edm') ||
genre.includes('electro') || genre.includes('trance') || genre.includes('electronic')) {
return '๐น';
}
if (genre.includes('hip hop') || genre.includes('rap') || genre.includes('trap')) {
return '๐ค';
}
if (genre.includes('rock') || genre.includes('punk')) {
return '๐ธ';
}
if (genre.includes('metal')) {
return '๐ค';
}
if (genre.includes('jazz') || genre.includes('blues')) {
return '๐บ';
}
if (genre.includes('pop')) {
return '๐ต';
}
if (genre.includes('r&b') || genre.includes('soul')) {
return '๐๏ธ';
}
if (genre.includes('country') || genre.includes('folk')) {
return '๐ช';
}
if (genre.includes('classical') || genre.includes('orchestra')) {
return '๐ป';
}
if (genre.includes('indie') || genre.includes('alternative')) {
return '๐ง';
}
if (genre.includes('latin') || genre.includes('reggaeton') || genre.includes('salsa')) {
return '๐';
}
if (genre.includes('reggae') || genre.includes('dancehall')) {
return '๐ด';
}
if (genre.includes('funk') || genre.includes('disco')) {
return '๐บ';
}
// Default
return '๐ถ';
}
function capitalizeGenre(genre) {
// Capitalize each word in genre, handling both spaces and slashes
return genre.split(/(\s|\/)/g)
.map(part => {
if (part === ' ' || part === '/') return part;
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function openGenrePlaylist(genre) {
try {
showLoadingOverlay(`Loading ${capitalizeGenre(genre)} playlist...`);
const response = await fetch(`/api/discover/genre/${encodeURIComponent(genre)}`);
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) {
const message = data.message || `No tracks found for ${genre}`;
showToast(message, 'info');
hideLoadingOverlay();
return;
}
selectedGenre = genre;
genreTracks = data.tracks;
// Open download modal
const playlistName = `${capitalizeGenre(genre)} Mix`;
const virtualPlaylistId = `genre_${genre.replace(/\s+/g, '_')}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks);
hideLoadingOverlay();
} catch (error) {
console.error(`Error opening ${genre} playlist:`, error);
showToast(`Failed to load ${genre} playlist`, 'error');
hideLoadingOverlay();
}
}
// ===============================
// TIME MACHINE (TABBED BY DECADE)
// ===============================
let decadeTracksCache = {}; // Store tracks for each decade
let activeDecade = null;
// Shared sync-status display block. Used by per-tab playlists
// (decade browser, genre browser) where we show download progress
// in the standard "โ completed | โณ pending | โ failed (N%)" format.
// ListenBrainz playlists use a different shape (total/matched/failed)
// because they show MATCHING progress against the library, not
// download progress, so they intentionally don't use this helper.
function _renderSyncStatusBlock(idPrefix) {
return `
โณSyncing to media server...
โ 0โณ 0โ 0(0%)
`;
}
// ===============================
// 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 || {});
async function loadTabs() {
try {
const tabsContainer = document.getElementById(cfg.tabsContainerId);
const contentsContainer = document.getElementById(cfg.contentsContainerId);
if (!tabsContainer || !contentsContainer) return;
const tabs = await cfg.fetchTabs();
if (!Array.isArray(tabs) || tabs.length === 0) {
tabsContainer.innerHTML = `
${cfg.emptyTabsMessage}
`;
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 = `
`;
},
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;
}
// 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 = '
';
return;
}
// For recommendations tab with multiple playlists, group into sub-tabs
if (tabId === 'recommendations' && playlists.length > 1) {
const { groups, groupOrder } = groupListenBrainzPlaylists(playlists);
// If only one group, no need for sub-tabs
if (groupOrder.length <= 1) {
const html = buildListenBrainzPlaylistsHtml(playlists, tabId);
container.innerHTML = html;
loadTracksForPlaylists(playlists);
return;
}
// Build sub-tabs bar
const firstGroup = activeListenBrainzSubTab && groupOrder.includes(activeListenBrainzSubTab)
? activeListenBrainzSubTab
: groupOrder[0];
activeListenBrainzSubTab = firstGroup;
let subTabsHtml = '
';
}
}
async function unblockDiscoveryArtist(id, name) {
try {
const res = await fetch(`/api/discover/artist-blacklist/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
showToast(`Unblocked ${name}`, 'success');
_dblLoadList();
}
} catch (e) {
showToast('Error unblocking artist', 'error');
}
}
// Backwards compat โ called during page init but now a no-op (modal handles it)
// โโ Your Artists (Liked Artists Pool) โโ
let _yourArtistsCtrl = null;
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...
`;
}
// 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...)';
}
// 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,
showErrorToast: true,
});
}
return _yourArtistsCtrl.load();
}
function _pollYourArtists() {
// Poll every 5s until artists appear, then stop
if (window._yaPoller) clearInterval(window._yaPoller);
let attempts = 0;
window._yaPoller = setInterval(async () => {
attempts++;
if (attempts > 60) { clearInterval(window._yaPoller); window._yaPoller = null; 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) {
clearInterval(window._yaPoller);
window._yaPoller = null;
loadYourArtists(); // Re-render with real data
}
} catch (e) { }
}, 5000);
}
function _renderYourArtistCard(artist) {
const _esc = (s) => escapeHtml(s || '');
const img = artist.image_url || '';
// Build metadata source badges (same pattern as library page)
const badges = [];
if (artist.spotify_artist_id) badges.push({ logo: SPOTIFY_LOGO_URL, fb: 'SP', title: 'Spotify' });
if (artist.itunes_artist_id) badges.push({ logo: ITUNES_LOGO_URL, fb: 'IT', title: 'Apple Music' });
if (artist.deezer_artist_id) badges.push({ logo: DEEZER_LOGO_URL, fb: 'Dz', title: 'Deezer' });
if (artist.discogs_artist_id) badges.push({ logo: DISCOGS_LOGO_URL, fb: 'DC', title: 'Discogs' });
const badgeHTML = badges.map(b =>
`
${b.logo ? `` : `${b.fb}`}
`
).join('');
// Origin dots (which services the artist came from)
const sources = artist.source_services || [];
const sourceColors = { spotify: '#1DB954', lastfm: '#D51007', tidal: '#00FFFF', deezer: '#A238FF' };
const originDots = sources.map(s =>
``
).join('');
const watchlistClass = artist.on_watchlist ? 'active' : '';
const hasId = artist.active_source_id && artist.active_source_id !== '';
// Navigate to Artists page (name click) โ source artist id, needs inline view
const navAction = hasId
? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(artist.active_source_id)}', '${escapeForInlineJs(artist.artist_name)}')`
: '';
// Open info modal (card body click) โ pass pool ID so we can look up all data
const infoAction = hasId
? `openYourArtistInfoModal(${artist.id})`
: '';
// Deezer fallback for images
const deezerFb = artist.deezer_artist_id ? `onerror="if(!this.dataset.tried){this.dataset.tried='1';this.src='https://api.deezer.com/artist/${artist.deezer_artist_id}/image?size=big'}else{this.style.display='none';this.nextElementSibling.style.display='flex'}"` : `onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"`;
return `
${img ? `` : ''}
♫
${badgeHTML}
${originDots}
${_esc(artist.artist_name)}
`;
}
async function openYourArtistInfoModal(poolId) {
const pool = (window._yaArtists || {})[poolId];
if (!pool) return;
const artistId = pool.active_source_id;
const artistName = pool.artist_name;
const imageUrl = pool.image_url || '';
const existing = document.getElementById('ya-info-modal-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'ya-info-modal-overlay';
overlay.className = 'modal-overlay';
overlay.style.zIndex = '10001';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
// Build matched source badges from pool data
const _mb = (logo, fb, title) => `
${logo ? `` : `${fb}`}
`;
const matchBadges = [];
if (pool.spotify_artist_id) matchBadges.push(_mb(SPOTIFY_LOGO_URL, 'SP', 'Matched on Spotify'));
if (pool.itunes_artist_id) matchBadges.push(_mb(ITUNES_LOGO_URL, 'IT', 'Matched on Apple Music'));
if (pool.deezer_artist_id) matchBadges.push(_mb(DEEZER_LOGO_URL, 'Dz', 'Matched on Deezer'));
if (pool.discogs_artist_id) matchBadges.push(_mb(DISCOGS_LOGO_URL, 'DC', 'Matched on Discogs'));
// Origin info
const sources = pool.source_services || [];
const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' };
const originText = sources.map(s => sourceNames[s] || s).join(', ');
overlay.innerHTML = `