';
}
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',
});
}
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);
}
async function loadYourAlbums() {
const section = document.getElementById('your-albums-section');
if (!section) 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 totalCount = (data.stats && data.stats.total) || 0;
if (totalCount === 0 && !data.stale) return; // Nothing to show yet
section.style.display = '';
yourAlbums = data.albums || [];
yourAlbumsTotal = data.total || 0;
yourAlbumsPage = 1;
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 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 = '';
_renderYourAlbumsGrid(yourAlbums);
_renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
if (data.stale && totalCount === 0) {
const grid = document.getElementById('your-albums-grid');
if (grid) grid.innerHTML = '
Fetching your albums from connected services...
';
_pollYourAlbums();
}
} catch (e) {
console.error('Error loading your albums:', e);
}
}
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 = '
';
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 = '