';
}
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 = '
';
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 = `