';
}
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);
discographyBtn.href = buildArtistDetailPath(artistId, artist.source || null);
// Keep the source on the link so source-only hero artists resolve to
// the correct artist-detail URL instead of being treated as library IDs.
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;
let _recommendedArtistsSource = 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, _recommendedArtistsSource);
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;
}
// 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';
// Render cards immediately with fallback images
_recommendedArtistsCache = data.artists;
_recommendedArtistsSource = source;
renderRecommendedArtistsModal(modal, data.artists, source);
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 = `
`;
});
grid.innerHTML = html;
}
function _renderYourAlbumsPagination(total, page) {
const container = document.getElementById('your-albums-pagination');
if (!container) return;
if (total <= YOUR_ALBUMS_PAGE_SIZE) { container.style.display = 'none'; return; }
container.style.display = '';
const totalPages = Math.ceil(total / YOUR_ALBUMS_PAGE_SIZE);
const start = (page - 1) * YOUR_ALBUMS_PAGE_SIZE + 1;
const end = Math.min(page * YOUR_ALBUMS_PAGE_SIZE, total);
container.innerHTML = `
${start}\u2013${end} of ${total}
`;
}
function _yourAlbumsPrevPage() {
if (yourAlbumsPage > 1) { yourAlbumsPage--; loadYourAlbumsGrid(); }
}
function _yourAlbumsNextPage() {
const totalPages = Math.ceil(yourAlbumsTotal / YOUR_ALBUMS_PAGE_SIZE);
if (yourAlbumsPage < totalPages) { yourAlbumsPage++; loadYourAlbumsGrid(); }
}
async function openYourAlbumDownload(index) {
const album = yourAlbums[index];
if (!album) { showToast('Album data not found', 'error'); return; }
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try {
// Per-source dispatch: open with whichever source has an ID for
// this album. For pure-Discogs collection items (no Spotify/
// Deezer match), dispatch goes straight to Discogs so the
// modal opens with Discogs context (vinyl/CD release detail,
// tracklist from Discogs). For Spotify saved albums (no
// discogs id), goes to Spotify. For multi-source albums
// (album exists in BOTH Spotify saved and Discogs collection,
// rare), tries streaming sources first since they have
// tracklists with proper IDs ready for download.
let albumData = null;
const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
const discogsId = album.discogs_release_id || album.discogs_id;
const trySources = [];
if (album.spotify_album_id) trySources.push(['spotify', album.spotify_album_id]);
if (album.deezer_album_id) trySources.push(['deezer', album.deezer_album_id]);
if (album.tidal_album_id) trySources.push(['tidal', album.tidal_album_id]);
if (discogsId) trySources.push(['discogs', discogsId]);
for (const [src, id] of trySources) {
const r = await fetch(`/api/discover/album/${src}/${id}?${nameParams}`);
if (r.ok) {
albumData = await r.json();
if (albumData && albumData.tracks && albumData.tracks.length > 0) break;
albumData = null; // empty payload โ try next
}
}
if (!albumData) {
// Last resort โ search by name
const r = await fetch(`/api/discover/album/spotify/search?${nameParams}`);
if (r.ok) albumData = await r.json();
}
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found for this album');
}
const tracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
return {
id: track.id, name: track.name, artists,
album: {
id: albumData.id, name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
const virtualId = `discover_album_${album.spotify_album_id || album.deezer_album_id || album.tidal_album_id || index}`;
const albumObj = {
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
images: albumData.images || [], artists: [{ name: album.artist_name }]
};
const artistObj = { id: null, name: album.artist_name };
await openDownloadMissingModalForArtistAlbum(virtualId, albumData.name, tracks, albumObj, artistObj, false);
hideLoadingOverlay();
} catch (e) {
console.error('Error opening your album download:', e);
showToast(`Failed to load album: ${e.message}`, 'error');
hideLoadingOverlay();
}
}
async function refreshYourAlbums() {
const btn = document.getElementById('your-albums-refresh-btn');
if (btn) btn.disabled = true;
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle) subtitle.textContent = 'Refreshing from connected services...';
try {
await fetch('/api/discover/your-albums/refresh?clear=true', { method: 'POST' });
showToast('Refresh started โ checking for new albums...', 'info');
const poll = setInterval(async () => {
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48');
const data = await resp.json();
if (data.success && data.stats && data.stats.total > 0) {
clearInterval(poll);
loadYourAlbums();
if (btn) btn.disabled = false;
}
} catch (e) { }
}, 4000);
setTimeout(() => { clearInterval(poll); if (btn) btn.disabled = false; }, 60000);
} catch (e) {
showToast('Failed to start refresh', 'error');
if (btn) btn.disabled = false;
}
}
async function openYourAlbumsSourcesModal() {
const existing = document.getElementById('ya-albums-sources-modal-overlay');
if (existing) existing.remove();
let enabled = ['spotify', 'tidal', 'deezer'];
let connected = [];
try {
const resp = await fetch('/api/discover/your-albums/sources');
if (resp.ok) {
const data = await resp.json();
if (data.enabled) enabled = data.enabled;
if (data.connected) connected = data.connected;
}
} catch (e) { }
const sourceInfo = [
{ id: 'spotify', label: 'Spotify', icon: '\uD83C\uDFB5' },
{ id: 'tidal', label: 'Tidal', icon: '\uD83C\uDF0A' },
{ id: 'deezer', label: 'Deezer', icon: '\uD83C\uDFB6' },
{ id: 'discogs', label: 'Discogs', icon: '\uD83D\uDCBF' },
];
const state = {};
sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); });
const overlay = document.createElement('div');
overlay.id = 'ya-albums-sources-modal-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const rows = sourceInfo.map(s => {
const isConnected = connected.includes(s.id);
const isOn = state[s.id];
return `
${s.icon}
${s.label}
${isConnected ? 'Connected' : 'Not connected'}
`;
}).join('');
overlay.innerHTML = `
Your Albums Sources
Choose which connected services contribute albums to this section.
${rows}
`;
document.body.appendChild(overlay);
window._yaaSourcesState = state;
}
// Source-id โ human label + setup hint shown when user tries to enable
// a disconnected source. Without this, the toggle silently bailed and
// users saw no feedback โ just a non-responsive switch.
const _YAA_DISCONNECTED_HINTS = {
spotify: 'Spotify not connected โ log in at Settings โ Connections first',
tidal: 'Tidal not connected โ set up Tidal in Settings โ Connections first',
deezer: 'Deezer not connected โ log in or set ARL token at Settings โ Connections first',
discogs: 'Discogs not connected โ paste your personal access token at Settings โ Connections first',
};
function _yaaShowDisconnectedHint(id) {
const msg = _YAA_DISCONNECTED_HINTS[id]
|| `${id} not connected โ set it up in Settings โ Connections first`;
if (typeof showToast === 'function') showToast(msg, 'warning');
}
function _yaaSourceRowClick(id) {
const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`);
if (row && row.classList.contains('disconnected')) {
_yaaShowDisconnectedHint(id);
return;
}
_yaaSourceToggle(id);
}
function _yaaSourceToggle(id) {
const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`);
if (row && row.classList.contains('disconnected')) {
_yaaShowDisconnectedHint(id);
return;
}
window._yaaSourcesState[id] = !window._yaaSourcesState[id];
const btn = document.getElementById(`yaa-toggle-${id}`);
if (btn) btn.classList.toggle('on', window._yaaSourcesState[id]);
}
async function _yaaSourcesSave() {
const enabledArr = Object.entries(window._yaaSourcesState).filter(([, v]) => v).map(([k]) => k);
if (enabledArr.length === 0) { showToast('Select at least one source', 'error'); return; }
try {
const resp = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ discover: { your_albums_sources: enabledArr.join(',') } })
});
if (resp.ok) {
document.getElementById('ya-albums-sources-modal-overlay')?.remove();
showToast('Sources saved โ refresh to apply', 'success');
const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', discogs: 'Discogs' };
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle) {
const names = enabledArr.map(s => sourceNames[s] || s).join(' and ');
subtitle.textContent = `Albums you\u2019ve saved on ${names}`;
}
} else {
showToast('Failed to save sources', 'error');
}
} catch (e) {
showToast('Failed to save sources', 'error');
}
}
async function downloadMissingYourAlbums() {
// Opens the same selectable-grid modal pattern used by Download
// Discography on the library page. User picks which missing albums
// they want, clicks Add to Wishlist, each album's tracks get
// resolved + added to the wishlist for the existing auto-download
// processor to pick up. Replaces the prior per-album direct-download
// loop which was silently failing โ actual downloads should go
// through the wishlist queue, not bypass it.
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=1000&status=missing');
const data = await resp.json();
if (!data.success || !data.albums || data.albums.length === 0) {
showToast('No missing albums to download', 'info');
return;
}
const missing = data.albums.filter(a => !a.in_library);
if (missing.length === 0) {
showToast('All albums are already in your library!', 'success');
return;
}
_openYourAlbumsBatchModal(missing);
} catch (e) {
console.error('Error loading missing your albums:', e);
showToast(`Error: ${e.message}`, 'error');
}
}
// Map a Your Albums row to the single best source-id the
// /api/artist//download-discography endpoint can resolve. Each row
// in the missing list typically only has one populated source-id (the
// service it was saved on), so this is just a priority pick.
function _yourAlbumsPickSource(album) {
if (album.spotify_album_id) return { id: String(album.spotify_album_id), source: 'spotify' };
if (album.deezer_album_id) return { id: String(album.deezer_album_id), source: 'deezer' };
if (album.tidal_album_id) return { id: String(album.tidal_album_id), source: 'tidal' };
const discogsId = album.discogs_release_id || album.discogs_id;
if (discogsId) return { id: String(discogsId), source: 'discogs' };
return null;
}
function _openYourAlbumsBatchModal(missingAlbums) {
// Reuses the .discog-modal styling from the library Download
// Discography flow โ same checkboxes, same Select All / Deselect
// All semantics, same footer. Single difference: each card carries
// its own artist+source (multi-artist) instead of all being one
// artist's discography.
const existing = document.getElementById('your-albums-batch-modal-overlay');
if (existing) existing.remove();
// Stash the source-id picks on the cards so the submit handler
// can build the per-album payload without re-mapping the array.
const rows = missingAlbums
.map((a, i) => ({ ...a, _src: _yourAlbumsPickSource(a), _index: i }))
.filter(a => a._src); // Skip albums with no usable source-id
if (rows.length === 0) {
showToast('No missing albums have a usable source ID to resolve', 'warning');
return;
}
const overlay = document.createElement('div');
overlay.className = 'discog-modal-overlay';
overlay.id = 'your-albums-batch-modal-overlay';
overlay.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 = '
`;
container.appendChild(loadingEl);
// Update toolbar
document.querySelector('.artmap-brand-text').textContent = 'Genre Map';
document.getElementById('artist-map-stats').textContent = 'Loading...';
try {
// Use cached data from picker or fetch fresh
const data = window._artMapGenreData || await fetch('/api/discover/artist-map/genres').then(r => r.json());
const loadingText = document.getElementById('artmap-genre-loading-text');
if (!data.success || !data.nodes.length) {
if (loadingText) loadingText.textContent = 'No artists with genre data found.';
return;
}
// Find the selected genre + closely related genres (high artist overlap)
const allGenres = data.genres;
const primary = allGenres.find(g => g.name === selectedGenre);
if (!primary) {
if (loadingText) loadingText.textContent = `Genre "${selectedGenre}" not found.`;
return;
}
const primarySet = new Set(primary.artist_ids);
// Find up to 4 related genres by artist overlap
const related = allGenres
.filter(g => g.name !== selectedGenre)
.map(g => {
const overlap = g.artist_ids.filter(id => primarySet.has(id)).length;
return { ...g, overlap };
})
.filter(g => g.overlap > primarySet.size * 0.1) // At least 10% overlap
.sort((a, b) => b.overlap - a.overlap)
.slice(0, 4);
const genres = [primary, ...related];
const totalArtists = genres.reduce((sum, g) => sum + g.artist_ids.length, 0);
document.getElementById('artist-map-stats').innerHTML =
`${escapeHtml(selectedGenre)} โพ ยท ${genres.length} genre${genres.length > 1 ? 's' : ''} ยท ${totalArtists} artists`;
// Build genre-island groups from the selected + related genres and lay
// them out as filled-disc islands on the water (shared engine).
const groups = genres.map(g => ({
name: g.name,
count: g.count,
nodes: (g.artist_ids || []).map(nid => data.nodes[nid]).filter(Boolean),
}));
_artMapLayoutIslands(groups);
_artMap.edges = [];
_artMapFitToContent();
const placedCount = _artMap.placed.filter(n => !n._isLabel).length;
_artMapSetupInteraction(canvas);
// Load images + render
if (loadingText) loadingText.textContent = `Rendering ${placedCount} artists...`;
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
_artMap.dirty = true;
_artMapBeginReveal();
// Stream images in throttled waves โ interactive immediately, sharpens in place.
_artMapStreamImages(_artMap.placed.filter(n => !n._isLabel));
} catch (err) {
console.error('Genre map error:', err);
const lt = container.querySelector('.artist-map-loading-text');
if (lt) lt.textContent = 'Error loading genre map';
}
}
function openArtistMapExplorerDirect(name) {
if (!name) return;
// Already in map โ just reload with new data, don't re-hide sections
_artMap._skipSectionToggle = true;
_openArtistMapExplorerWithName(name);
}
async function openArtistMapExplorer() {
const name = await _showArtistMapSearchPrompt();
if (!name) return;
_openArtistMapExplorerWithName(name);
}
async function _openArtistMapExplorerWithName(name) {
const container = document.getElementById('artist-map-container');
if (!container) return;
const skipToggle = _artMap._skipSectionToggle;
_artMap._skipSectionToggle = false;
if (!skipToggle) {
document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => {
el._prevDisplay = el.style.display;
el.style.display = 'none';
});
}
container.style.display = 'flex';
const canvas = document.getElementById('artist-map-canvas');
_artMap.canvas = canvas;
_artMap.ctx = canvas.getContext('2d');
_artMap.width = container.clientWidth;
_artMap.height = container.clientHeight - 50;
canvas.width = _artMap.width * window.devicePixelRatio;
canvas.height = _artMap.height * window.devicePixelRatio;
canvas.style.width = _artMap.width + 'px';
canvas.style.height = _artMap.height + 'px';
_artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
_artMap.offsetX = _artMap.width / 2;
_artMap.offsetY = _artMap.height / 2;
_artMap.placed = [];
_artMap.edges = [];
_artMap.images = {};
_artMap._nodeById = null;
_artMap.dirty = true;
const loadingEl = document.createElement('div');
loadingEl.id = 'artist-map-loading';
loadingEl.innerHTML = `
Exploring ${escapeHtml(name)}...
`;
container.appendChild(loadingEl);
document.querySelector('.artmap-brand-text').textContent = 'Artist Explorer';
try {
const resp = await fetch(`/api/discover/artist-map/explore?name=${encodeURIComponent(name.trim())}`);
const data = await resp.json();
if (!data.success || !data.nodes.length) {
const lt = document.querySelector('.artist-map-loading-text');
if (lt) {
lt.textContent = resp.status === 404
? `"${name}" doesn't appear to be a real artist. Try a different name.`
: `No data found for "${name}". Try a different artist.`;
}
setTimeout(() => {
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
closeArtistMap();
}, 2500);
return;
}
const ring1Count = data.nodes.filter(n => n.ring === 1).length;
const ring2Count = data.nodes.filter(n => n.ring === 2).length;
document.getElementById('artist-map-stats').textContent =
`${data.center} ยท ${ring1Count} similar ยท ${ring2Count} extended`;
// Group the center + all discovered artists into genre islands. The
// center artist is focal. Discovery edges (center โ similar โ extended)
// are remapped so the hover constellation still traces how you got from
// one artist to another across the islands.
const rawNodes = data.nodes.map(n => ({ ...n, _focal: n.ring === 0 || n.type === 'center' }));
const groups = _artMapGroupByGenre(rawNodes);
_artMapLayoutIslands(groups);
_artMap.edges = _artMapRemapEdges(data.edges);
_artMapFitToContent();
_artMapSetupInteraction(canvas);
// Load images
const loadingText = container.querySelector('.artist-map-loading-text');
if (loadingText) loadingText.textContent = `Loading ${_artMap.placed.length} artists...`;
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
_artMap.dirty = true;
_artMapBeginReveal();
// Stream images in throttled waves โ interactive immediately, sharpens in place.
_artMapStreamImages(_artMap.placed);
} catch (err) {
console.error('Artist explorer error:', err);
const lt = container.querySelector('.artist-map-loading-text');
if (lt) lt.textContent = 'Error loading explorer';
}
}
function _showArtistMapSearchPrompt() {
// Search the metadata source and make the user PICK a real artist, rather
// than exploring whatever loose text they typed. Resolves with the chosen
// artist's resolved name (which the explorer hands to /artist-map/explore),
// or null if cancelled.
return new Promise(resolve => {
const existing = document.getElementById('artmap-search-prompt');
if (existing) existing.remove();
let done = false;
let overlay;
const finish = (val) => { if (done) return; done = true; if (overlay) overlay.remove(); resolve(val); };
overlay = document.createElement('div');
overlay.id = 'artmap-search-prompt';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) finish(null); };
overlay.innerHTML = `
`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
try {
const resp = await fetch(`/api/discover/genre-deep-dive?genre=${encodeURIComponent(genre)}`);
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
if (!data.success) throw new Error('Failed');
const body = document.getElementById('genre-dive-body');
if (!body) return;
// Update header with counts
const subtitle = document.querySelector('.genre-dive-subtitle');
if (subtitle) {
const parts = [];
if (data.artists?.length) parts.push(`${data.artists.length} artist${data.artists.length !== 1 ? 's' : ''}`);
if (data.tracks?.length) parts.push(`${data.tracks.length} track${data.tracks.length !== 1 ? 's' : ''}`);
if (data.albums?.length) parts.push(`${data.albums.length} album${data.albums.length !== 1 ? 's' : ''}`);
subtitle.textContent = parts.length ? parts.join(' ยท ') : 'Genre Deep Dive';
}
let html = '';
// Related genres โ clickable pills that reload the modal
if (data.related_genres && data.related_genres.length) {
html += `
Related Genres
${data.related_genres.map(rg => `
`).join('')}
`;
}
// Artists section โ clickable, navigates to artist page
// Uses library_id for in-library artists (source-agnostic), falls back to search by name
if (data.artists && data.artists.length) {
html += `