The streaming fix in Phase 4 still rebuilt the ENTIRE offscreen buffer (~1500 nodes) on each image wave, and any hover/pan during streaming hit that same dirty flag — so interacting while images loaded redrew the whole world over and over (the 'laggy until all images load' jank). Now each arriving image composites ONLY its own node into the existing buffer (_artMapCompositeNode) and does a cheap rAF-coalesced blit — no full rebuild. The per-node draw is extracted into _artMapDrawNodeToBuffer so the full rebuild and the incremental compositor share identical drawing (can't drift). Falls back to a full rebuild only if the buffer isn't built yet. Pan/hover stay at blit-speed the entire time images stream in.
8942 lines
383 KiB
JavaScript
8942 lines
383 KiB
JavaScript
// == DISCOVER PAGE ==
|
||
// ============================================
|
||
|
||
let discoverHeroIndex = 0;
|
||
let discoverHeroArtists = [];
|
||
let discoverHeroInterval = null;
|
||
let discoverPageInitialized = false;
|
||
|
||
// Store discover playlist tracks for download/sync functionality
|
||
let discoverReleaseRadarTracks = [];
|
||
let discoverWeeklyTracks = [];
|
||
let discoverRecentAlbums = [];
|
||
let discoverSeasonalAlbums = [];
|
||
let discoverSeasonalTracks = [];
|
||
let currentSeasonKey = null;
|
||
|
||
// Personalized playlists storage
|
||
let personalizedPopularPicks = [];
|
||
let personalizedHiddenGems = [];
|
||
let personalizedDailyMixes = [];
|
||
let personalizedDiscoveryShuffle = [];
|
||
let buildPlaylistSelectedArtists = [];
|
||
|
||
async function loadDiscoverPage() {
|
||
console.log('Loading discover page...');
|
||
|
||
// Load all sections
|
||
await Promise.all([
|
||
loadDiscoverHero(),
|
||
loadYourArtists(),
|
||
loadYourAlbums(),
|
||
loadDiscoverRecentReleases(),
|
||
loadSeasonalContent(), // Seasonal discovery
|
||
// loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists (HIDDEN)
|
||
loadDiscoverReleaseRadar(),
|
||
loadDiscoverWeekly(),
|
||
loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool
|
||
loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool
|
||
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
|
||
loadBecauseYouListenTo(), // Personalized by listening stats
|
||
loadCacheUndiscoveredAlbums(), // From metadata cache
|
||
loadCacheGenreNewReleases(), // From metadata cache
|
||
loadCacheLabelExplorer(), // From metadata cache
|
||
loadCacheDeepCuts(), // From metadata cache
|
||
loadCacheGenreExplorer(), // From metadata cache
|
||
initializeLastfmRadioSection(), // Last.fm Radio section (gated on API key)
|
||
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
|
||
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
|
||
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre)
|
||
loadListenBrainzPlaylistsFromBackend(), // Load ListenBrainz playlist states for persistence
|
||
loadDiscoveryBlacklist() // Blocked artists list
|
||
]);
|
||
|
||
// Check for active syncs after page load
|
||
checkForActiveDiscoverSyncs();
|
||
}
|
||
|
||
async function checkForActiveDiscoverSyncs() {
|
||
// Check if Fresh Tape sync is active
|
||
try {
|
||
const releaseRadarResponse = await fetch('/api/sync/status/discover_release_radar');
|
||
if (releaseRadarResponse.ok) {
|
||
const data = await releaseRadarResponse.json();
|
||
if (data.status === 'syncing' || data.status === 'starting') {
|
||
console.log('🔄 Resuming Fresh Tape sync polling after page refresh');
|
||
|
||
// Show status display
|
||
const statusDisplay = document.getElementById('release-radar-sync-status');
|
||
if (statusDisplay) {
|
||
statusDisplay.style.display = 'block';
|
||
}
|
||
|
||
// Disable button
|
||
const syncButton = document.getElementById('release-radar-sync-btn');
|
||
if (syncButton) {
|
||
syncButton.disabled = true;
|
||
syncButton.style.opacity = '0.5';
|
||
syncButton.style.cursor = 'not-allowed';
|
||
}
|
||
|
||
// Resume polling
|
||
startDiscoverSyncPolling('release_radar', 'discover_release_radar');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Sync not active, ignore
|
||
}
|
||
|
||
// Check if The Archives sync is active
|
||
try {
|
||
const discoveryWeeklyResponse = await fetch('/api/sync/status/discover_discovery_weekly');
|
||
if (discoveryWeeklyResponse.ok) {
|
||
const data = await discoveryWeeklyResponse.json();
|
||
if (data.status === 'syncing' || data.status === 'starting') {
|
||
console.log('🔄 Resuming The Archives sync polling after page refresh');
|
||
|
||
// Show status display
|
||
const statusDisplay = document.getElementById('discovery-weekly-sync-status');
|
||
if (statusDisplay) {
|
||
statusDisplay.style.display = 'block';
|
||
}
|
||
|
||
// Disable button
|
||
const syncButton = document.getElementById('discovery-weekly-sync-btn');
|
||
if (syncButton) {
|
||
syncButton.disabled = true;
|
||
syncButton.style.opacity = '0.5';
|
||
syncButton.style.cursor = 'not-allowed';
|
||
}
|
||
|
||
// Resume polling
|
||
startDiscoverSyncPolling('discovery_weekly', 'discover_discovery_weekly');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Sync not active, ignore
|
||
}
|
||
|
||
// Check if Seasonal Playlist sync is active
|
||
try {
|
||
const seasonalResponse = await fetch('/api/sync/status/discover_seasonal_playlist');
|
||
if (seasonalResponse.ok) {
|
||
const data = await seasonalResponse.json();
|
||
if (data.status === 'syncing' || data.status === 'starting') {
|
||
console.log('🔄 Resuming Seasonal Playlist sync polling after page refresh');
|
||
|
||
const statusDisplay = document.getElementById('seasonal-playlist-sync-status');
|
||
if (statusDisplay) {
|
||
statusDisplay.style.display = 'block';
|
||
}
|
||
|
||
const syncButton = document.getElementById('seasonal-playlist-sync-btn');
|
||
if (syncButton) {
|
||
syncButton.disabled = true;
|
||
syncButton.style.opacity = '0.5';
|
||
syncButton.style.cursor = 'not-allowed';
|
||
}
|
||
|
||
startDiscoverSyncPolling('seasonal_playlist', 'discover_seasonal_playlist');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Sync not active, ignore
|
||
}
|
||
}
|
||
|
||
async function loadDiscoverHero() {
|
||
try {
|
||
const response = await fetch('/api/discover/hero');
|
||
if (!response.ok) {
|
||
console.error('Failed to fetch discover hero');
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (!data.success || !data.artists || data.artists.length === 0) {
|
||
console.log('No hero artists available');
|
||
showDiscoverHeroEmpty();
|
||
return;
|
||
}
|
||
|
||
discoverHeroArtists = data.artists;
|
||
discoverHeroIndex = 0;
|
||
|
||
// Display first artist
|
||
displayDiscoverHeroArtist(discoverHeroArtists[0]);
|
||
|
||
// Start slideshow (change every 8 seconds)
|
||
if (discoverHeroInterval) {
|
||
clearInterval(discoverHeroInterval);
|
||
}
|
||
if (discoverHeroArtists.length > 1) {
|
||
discoverHeroInterval = setInterval(() => {
|
||
discoverHeroIndex = (discoverHeroIndex + 1) % discoverHeroArtists.length;
|
||
displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]);
|
||
}, 8000);
|
||
}
|
||
|
||
// Check if all hero artists are already watched
|
||
checkAllHeroWatchlistStatus();
|
||
|
||
} catch (error) {
|
||
console.error('Error loading discover hero:', error);
|
||
showDiscoverHeroEmpty();
|
||
}
|
||
}
|
||
|
||
function displayDiscoverHeroArtist(artist) {
|
||
const titleEl = document.getElementById('discover-hero-title');
|
||
const subtitleEl = document.getElementById('discover-hero-subtitle');
|
||
const metaEl = document.getElementById('discover-hero-meta');
|
||
const imageEl = document.getElementById('discover-hero-image');
|
||
const bgEl = document.getElementById('discover-hero-bg');
|
||
|
||
if (titleEl) {
|
||
titleEl.textContent = artist.artist_name;
|
||
}
|
||
|
||
if (subtitleEl) {
|
||
// Show recommendation context based on occurrence count
|
||
let subtitle = '';
|
||
if (artist.occurrence_count > 1) {
|
||
subtitle = `Similar to ${artist.occurrence_count} artists in your watchlist`;
|
||
} else {
|
||
subtitle = 'Similar to an artist in your watchlist';
|
||
}
|
||
subtitleEl.textContent = subtitle;
|
||
}
|
||
|
||
// Build metadata section with popularity and genres
|
||
if (metaEl) {
|
||
let metaHTML = '<div class="discover-hero-meta-content">';
|
||
|
||
// Add popularity indicator
|
||
if (artist.popularity !== undefined && artist.popularity > 0) {
|
||
const popularityClass = artist.popularity >= 80 ? 'high' :
|
||
artist.popularity >= 50 ? 'medium' : 'low';
|
||
metaHTML += `
|
||
<div class="hero-meta-item hero-popularity ${popularityClass}">
|
||
<span class="meta-icon">⭐</span>
|
||
<span class="meta-value">${artist.popularity}/100</span>
|
||
<span class="meta-label">Popularity</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Add genre tags
|
||
if (artist.genres && artist.genres.length > 0) {
|
||
metaHTML += '<div class="hero-meta-item hero-genres">';
|
||
artist.genres.slice(0, 3).forEach(genre => {
|
||
metaHTML += `<span class="genre-tag">${genre}</span>`;
|
||
});
|
||
metaHTML += '</div>';
|
||
}
|
||
|
||
metaHTML += '</div>';
|
||
metaEl.innerHTML = metaHTML;
|
||
}
|
||
|
||
if (imageEl && artist.image_url) {
|
||
imageEl.innerHTML = `<img src="${artist.image_url}" alt="${artist.artist_name}">`;
|
||
} else if (imageEl) {
|
||
imageEl.innerHTML = '<div class="hero-image-placeholder">🎧</div>';
|
||
}
|
||
|
||
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 = `
|
||
<div class="modal-container playlist-modal recommended-modal">
|
||
<div class="playlist-modal-header">
|
||
<div class="playlist-header-content" style="width: 100%;">
|
||
<h2>Recommended Artists</h2>
|
||
<div class="playlist-quick-info">
|
||
<span class="playlist-track-count">Loading...</span>
|
||
</div>
|
||
</div>
|
||
<span class="playlist-modal-close" onclick="closeRecommendedArtistsModal()">×</span>
|
||
</div>
|
||
<div class="playlist-modal-body">
|
||
<div class="recommended-loading">Loading recommended artists...</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<div class="recommended-empty-state">
|
||
No recommended artists yet.<br>
|
||
Run a watchlist scan to generate recommendations.
|
||
</div>
|
||
`;
|
||
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 = `<img src="${info.image_url}" alt="" loading="lazy"
|
||
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">`;
|
||
}
|
||
}
|
||
if (card && info.genres && info.genres.length > 0) {
|
||
const genresContainer = card.querySelector('.recommended-card-genres');
|
||
if (genresContainer) {
|
||
genresContainer.innerHTML = info.genres.map(g =>
|
||
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||
).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 =>
|
||
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||
).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 = `
|
||
<div class="recommended-empty-state">Error loading recommended artists.</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function renderRecommendedArtistsModal(modal, artists, source = null) {
|
||
modal.innerHTML = `
|
||
<div class="modal-container playlist-modal recommended-modal">
|
||
<div class="playlist-modal-header">
|
||
<div class="playlist-header-content" style="width: 100%;">
|
||
<h2>Recommended Artists</h2>
|
||
<div class="playlist-quick-info">
|
||
<span class="playlist-track-count">${artists.length} artist${artists.length !== 1 ? 's' : ''}</span>
|
||
</div>
|
||
</div>
|
||
<span class="playlist-modal-close" onclick="closeRecommendedArtistsModal()">×</span>
|
||
</div>
|
||
<div class="playlist-modal-body">
|
||
<div class="recommended-actions-bar">
|
||
<div class="recommended-search-container">
|
||
<input type="text"
|
||
class="recommended-search-input"
|
||
id="recommended-search-input"
|
||
placeholder="Search recommended artists..."
|
||
oninput="filterRecommendedArtists()">
|
||
</div>
|
||
<button class="recommended-add-all-btn" id="recommended-add-all-btn"
|
||
onclick="addAllRecommendedToWatchlist(this)">
|
||
Add All to Watchlist
|
||
</button>
|
||
</div>
|
||
<div class="recommended-artists-grid" id="recommended-artists-grid">
|
||
${artists.map(artist => {
|
||
const genreTags = (artist.genres || []).slice(0, 3).map(g =>
|
||
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||
).join('');
|
||
const similarText = artist.occurrence_count > 1
|
||
? `Similar to ${artist.occurrence_count} in your watchlist`
|
||
: 'Similar to an artist in your watchlist';
|
||
const artistSource = artist.source || source || _recommendedArtistsSource || '';
|
||
return `
|
||
<div class="recommended-artist-card"
|
||
data-artist-name="${escapeHtml(artist.artist_name).toLowerCase()}"
|
||
data-artist-id="${artist.artist_id}"
|
||
data-artist-source="${escapeHtml(artistSource)}">
|
||
<button class="recommended-card-watchlist-btn"
|
||
data-artist-id="${artist.artist_id}"
|
||
data-artist-name="${escapeHtml(artist.artist_name)}">
|
||
Add to Watchlist
|
||
</button>
|
||
<a class="recommended-card-link" href="${buildArtistDetailPath(artist.artist_id, artistSource || null)}"
|
||
onclick="closeRecommendedArtistsModal()"
|
||
style="display:block;text-decoration:none;color:inherit;">
|
||
<div class="recommended-card-image">
|
||
${artist.image_url ? `
|
||
<img src="${artist.image_url}"
|
||
alt="${escapeHtml(artist.artist_name)}"
|
||
loading="lazy"
|
||
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">
|
||
` : `
|
||
<div class="recommended-card-image-fallback">🎤</div>
|
||
`}
|
||
</div>
|
||
<div class="recommended-card-info">
|
||
<span class="recommended-card-name">${escapeHtml(artist.artist_name)}</span>
|
||
<span class="recommended-card-similarity">${similarText}</span>
|
||
<div class="recommended-card-genres">${genreTags}</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 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);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
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 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) => `
|
||
<button class="hero-indicator ${index === discoverHeroIndex ? 'active' : ''}"
|
||
onclick="jumpToDiscoverHeroSlide(${index})"
|
||
aria-label="Go to slide ${index + 1}"></button>
|
||
`).join('');
|
||
}
|
||
|
||
function jumpToDiscoverHeroSlide(index) {
|
||
if (!discoverHeroArtists || index < 0 || index >= discoverHeroArtists.length) return;
|
||
|
||
discoverHeroIndex = index;
|
||
displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]);
|
||
updateDiscoverHeroIndicators();
|
||
}
|
||
|
||
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 `
|
||
<div class="discover-card" onclick="openDownloadModalForRecentAlbum(${index})" style="cursor: pointer;">
|
||
<div class="discover-card-image">
|
||
<img src="${coverUrl}" alt="${album.album_name}" loading="lazy">
|
||
</div>
|
||
<div class="discover-card-info">
|
||
<h4 class="discover-card-title">${album.album_name}</h4>
|
||
<p class="discover-card-subtitle">${album.artist_name}</p>
|
||
<p class="discover-card-meta">${album.release_date}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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: () =>
|
||
'<div class="discover-loading"><div class="loading-spinner"></div><p>Fetching your albums from connected services...</p></div>',
|
||
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 = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading...</p></div>';
|
||
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 = '<div class="spotify-library-empty"><p>Failed to load albums</p></div>';
|
||
}
|
||
}
|
||
|
||
function _renderYourAlbumsGrid(albums) {
|
||
const grid = document.getElementById('your-albums-grid');
|
||
if (!grid) return;
|
||
if (!albums || albums.length === 0) {
|
||
grid.innerHTML = '<div class="spotify-library-empty"><p>No albums found</p></div>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
albums.forEach((album, index) => {
|
||
const coverUrl = album.image_url || '/static/placeholder-album.png';
|
||
const year = album.release_date ? album.release_date.substring(0, 4) : '';
|
||
const badgeClass = album.in_library ? 'owned' : 'missing';
|
||
const badgeIcon = album.in_library ? '\u2713' : '\u2193';
|
||
const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : '';
|
||
const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 ');
|
||
const sources = (album.source_services || []).join(', ');
|
||
html += `
|
||
<div class="spotify-library-card" onclick="openYourAlbumDownload(${index})" title="${escapeHtml(album.album_name)} \u2014 ${escapeHtml(album.artist_name)}">
|
||
<div class="spotify-library-card-img">
|
||
<img src="${coverUrl}" alt="${escapeHtml(album.album_name)}" loading="lazy">
|
||
<div class="spotify-library-card-badge ${badgeClass}">${badgeIcon}</div>
|
||
</div>
|
||
<div class="spotify-library-card-info">
|
||
<p class="spotify-library-card-title">${escapeHtml(album.album_name)}</p>
|
||
<p class="spotify-library-card-artist">${escapeHtml(album.artist_name)}</p>
|
||
<p class="spotify-library-card-meta">${escapeHtml(meta)}</p>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
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 = `
|
||
<button class="spotify-library-page-btn" onclick="_yourAlbumsPrevPage()" ${page <= 1 ? 'disabled' : ''}>← Previous</button>
|
||
<span class="spotify-library-page-info">${start}\u2013${end} of ${total}</span>
|
||
<button class="spotify-library-page-btn" onclick="_yourAlbumsNextPage()" ${page >= totalPages ? 'disabled' : ''}>Next →</button>
|
||
`;
|
||
}
|
||
|
||
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 `
|
||
<div class="ya-source-row${isConnected ? '' : ' disconnected'}" data-yaa-source="${s.id}" onclick="_yaaSourceRowClick('${s.id}')">
|
||
<div class="ya-source-row-left">
|
||
<span style="font-size:18px">${s.icon}</span>
|
||
<div>
|
||
<div class="ya-source-name">${s.label}</div>
|
||
<div class="ya-source-status">${isConnected ? 'Connected' : 'Not connected'}</div>
|
||
</div>
|
||
</div>
|
||
<button class="ya-source-toggle${isOn ? ' on' : ''}" id="yaa-toggle-${s.id}" onclick="event.stopPropagation();_yaaSourceToggle('${s.id}')"></button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
overlay.innerHTML = `
|
||
<div class="ya-sources-modal">
|
||
<h2>Your Albums Sources</h2>
|
||
<p class="ya-sources-desc">Choose which connected services contribute albums to this section.</p>
|
||
<div class="ya-sources-list">${rows}</div>
|
||
<div class="ya-sources-footer">
|
||
<button class="ya-sources-cancel-btn" onclick="document.getElementById('ya-albums-sources-modal-overlay').remove()">Cancel</button>
|
||
<button class="ya-sources-save-btn" onclick="_yaaSourcesSave()">Save</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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/<id>/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 = `
|
||
<div class="discog-modal">
|
||
<div class="discog-modal-hero">
|
||
<div class="discog-modal-hero-overlay"></div>
|
||
<div class="discog-modal-hero-content">
|
||
<h2 class="discog-modal-title">Add Missing Albums to Wishlist</h2>
|
||
<p class="discog-modal-artist">${rows.length} albums missing from your library</p>
|
||
</div>
|
||
<button class="discog-modal-close" onclick="_closeYourAlbumsBatchModal()">×</button>
|
||
</div>
|
||
<div class="discog-filter-bar">
|
||
<div class="discog-filters"></div>
|
||
<div class="discog-select-actions">
|
||
<button class="discog-select-btn" onclick="_yourAlbumsBatchSelectAll(true)">Select All</button>
|
||
<button class="discog-select-btn" onclick="_yourAlbumsBatchSelectAll(false)">Deselect All</button>
|
||
</div>
|
||
</div>
|
||
<div class="discog-grid" id="your-albums-batch-grid">
|
||
${rows.map((r, i) => _renderYourAlbumsBatchCard(r, i)).join('')}
|
||
</div>
|
||
<div class="discog-progress" id="your-albums-batch-progress" style="display:none;"></div>
|
||
<div class="discog-footer" id="your-albums-batch-footer">
|
||
<div class="discog-footer-info" id="your-albums-batch-footer-info"></div>
|
||
<div class="discog-footer-actions">
|
||
<button class="discog-cancel-btn" onclick="_closeYourAlbumsBatchModal()">Cancel</button>
|
||
<button class="discog-submit-btn" id="your-albums-batch-submit-btn">
|
||
<span class="discog-submit-icon">⬇</span>
|
||
<span id="your-albums-batch-submit-text">Add to Wishlist</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
// Stash row data on the overlay for the submit handler — keeps the
|
||
// multi-artist source info available without re-fetching.
|
||
overlay._yourAlbumsRows = rows;
|
||
|
||
requestAnimationFrame(() => overlay.classList.add('visible'));
|
||
_updateYourAlbumsBatchFooterCount();
|
||
|
||
document.getElementById('your-albums-batch-submit-btn')?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
_startYourAlbumsBatchAddToWishlist();
|
||
});
|
||
}
|
||
|
||
|
||
function _renderYourAlbumsBatchCard(row, index) {
|
||
const albumName = row.album_name || '';
|
||
const artistName = row.artist_name || '';
|
||
const year = row.release_date ? row.release_date.substring(0, 4) : '';
|
||
const tracks = row.total_tracks || 0;
|
||
const img = row.image_url || '';
|
||
const src = row._src?.source || '';
|
||
return `
|
||
<label class="discog-card" data-type="album" style="animation-delay:${index * 0.03}s">
|
||
<input type="checkbox" class="your-albums-batch-cb"
|
||
data-row-index="${row._index}" data-tracks="${tracks}" checked
|
||
onchange="_updateYourAlbumsBatchFooterCount()">
|
||
<div class="discog-card-art">
|
||
${img ? `<img src="${escapeHtml(img)}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
|
||
</div>
|
||
<div class="discog-card-info">
|
||
<div class="discog-card-title">${escapeHtml(albumName)}</div>
|
||
<div class="discog-card-meta">${escapeHtml(artistName)}${year ? ' · ' + year : ''}${tracks ? ' · ' + tracks + ' tracks' : ''}${src ? ' · ' + src : ''}</div>
|
||
</div>
|
||
<div class="discog-card-check"></div>
|
||
</label>
|
||
`;
|
||
}
|
||
|
||
|
||
function _yourAlbumsBatchSelectAll(select) {
|
||
document.querySelectorAll('.your-albums-batch-cb').forEach(cb => {
|
||
if (cb.closest('.discog-card').style.display !== 'none') cb.checked = select;
|
||
});
|
||
_updateYourAlbumsBatchFooterCount();
|
||
}
|
||
|
||
|
||
function _updateYourAlbumsBatchFooterCount() {
|
||
const checked = document.querySelectorAll('.your-albums-batch-cb:checked');
|
||
let releases = 0, tracks = 0;
|
||
checked.forEach(cb => {
|
||
if (cb.closest('.discog-card').style.display !== 'none') {
|
||
releases++;
|
||
tracks += parseInt(cb.dataset.tracks) || 0;
|
||
}
|
||
});
|
||
const info = document.getElementById('your-albums-batch-footer-info');
|
||
const btn = document.getElementById('your-albums-batch-submit-text');
|
||
if (info) info.textContent = `${releases} album${releases !== 1 ? 's' : ''}${tracks ? ' · ' + tracks + ' tracks' : ''}`;
|
||
if (btn) btn.textContent = releases > 0 ? `Add ${releases} to Wishlist` : 'Select albums';
|
||
const submitBtn = document.getElementById('your-albums-batch-submit-btn');
|
||
if (submitBtn) submitBtn.disabled = releases === 0;
|
||
}
|
||
|
||
|
||
function _closeYourAlbumsBatchModal() {
|
||
const overlay = document.getElementById('your-albums-batch-modal-overlay');
|
||
if (overlay) {
|
||
overlay.classList.remove('visible');
|
||
setTimeout(() => overlay.remove(), 200);
|
||
}
|
||
}
|
||
|
||
|
||
async function _startYourAlbumsBatchAddToWishlist() {
|
||
const overlay = document.getElementById('your-albums-batch-modal-overlay');
|
||
if (!overlay) return;
|
||
const rows = overlay._yourAlbumsRows || [];
|
||
|
||
// Collect selected row indices from the checked checkboxes.
|
||
const selectedRowIndices = [];
|
||
document.querySelectorAll('.your-albums-batch-cb:checked').forEach(cb => {
|
||
if (cb.closest('.discog-card').style.display !== 'none') {
|
||
selectedRowIndices.push(parseInt(cb.dataset.rowIndex));
|
||
}
|
||
});
|
||
const selected = rows.filter(r => selectedRowIndices.includes(r._index));
|
||
if (selected.length === 0) return;
|
||
|
||
// Switch to progress view.
|
||
const grid = document.getElementById('your-albums-batch-grid');
|
||
const progress = document.getElementById('your-albums-batch-progress');
|
||
const footer = document.getElementById('your-albums-batch-footer');
|
||
const filterBar = overlay.querySelector('.discog-filter-bar');
|
||
|
||
if (grid) grid.style.display = 'none';
|
||
if (filterBar) filterBar.style.display = 'none';
|
||
if (progress) {
|
||
progress.style.display = '';
|
||
progress.innerHTML = '';
|
||
}
|
||
|
||
selected.forEach(row => {
|
||
const item = document.createElement('div');
|
||
item.className = 'discog-progress-item active';
|
||
item.id = `your-albums-batch-prog-${row._src.source}-${row._src.id}`;
|
||
item.innerHTML = `
|
||
<div class="discog-prog-art">${row.image_url ? `<img src="${escapeHtml(row.image_url)}">` : '🎵'}</div>
|
||
<div class="discog-prog-info">
|
||
<div class="discog-prog-title">${escapeHtml(row.album_name || '')}</div>
|
||
<div class="discog-prog-status">Waiting...</div>
|
||
</div>
|
||
<div class="discog-prog-icon"><div class="discog-spinner"></div></div>
|
||
`;
|
||
progress.appendChild(item);
|
||
});
|
||
|
||
const submitBtn = document.getElementById('your-albums-batch-submit-btn');
|
||
if (submitBtn) submitBtn.style.display = 'none';
|
||
if (footer) {
|
||
const info = document.getElementById('your-albums-batch-footer-info');
|
||
if (info) info.textContent = 'Processing... this may take a moment';
|
||
}
|
||
|
||
// Build per-album payload matching the discography endpoint contract.
|
||
// URL artist_id is functionally unused by the endpoint when per-album
|
||
// metadata is supplied — backend resolves each album through its own
|
||
// `source` + `artist_name`. Placeholder 'your-albums' makes the route
|
||
// match without picking an arbitrary library artist.
|
||
const albumsPayload = selected.map(r => ({
|
||
id: r._src.id,
|
||
name: r.album_name || '',
|
||
artist_name: r.artist_name || '',
|
||
source: r._src.source,
|
||
}));
|
||
|
||
try {
|
||
const response = await fetch(`/api/artist/your-albums/download-discography`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
albums: albumsPayload,
|
||
artist_name: 'Your Albums',
|
||
source: null,
|
||
}),
|
||
});
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let totalAdded = 0, totalSkipped = 0;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const data = JSON.parse(line);
|
||
if (data.status === 'complete') {
|
||
totalAdded = data.total_added || 0;
|
||
totalSkipped = data.total_skipped || 0;
|
||
} else if (data.album_id) {
|
||
// Find the matching progress card — match by composite source-id
|
||
// pair since the same album_id could appear across sources.
|
||
const matching = selected.find(s => s._src.id === String(data.album_id));
|
||
if (matching) {
|
||
const item = document.getElementById(`your-albums-batch-prog-${matching._src.source}-${matching._src.id}`);
|
||
if (item) {
|
||
const status = item.querySelector('.discog-prog-status');
|
||
const icon = item.querySelector('.discog-prog-icon');
|
||
if (data.status === 'done') {
|
||
if (status) status.textContent = `${data.tracks_added || 0} added · ${data.tracks_skipped || 0} skipped`;
|
||
if (icon) icon.innerHTML = '✓';
|
||
item.classList.add('done');
|
||
item.classList.remove('active');
|
||
} else if (data.status === 'error') {
|
||
if (status) status.textContent = `Error: ${data.message || 'unknown'}`;
|
||
if (icon) icon.innerHTML = '✗';
|
||
item.classList.add('error');
|
||
item.classList.remove('active');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (parseErr) {
|
||
console.debug('your-albums batch ndjson parse:', parseErr);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (footer) {
|
||
const info = document.getElementById('your-albums-batch-footer-info');
|
||
if (info) info.textContent = `${totalAdded} tracks added to wishlist · ${totalSkipped} skipped`;
|
||
}
|
||
if (submitBtn) {
|
||
submitBtn.style.display = '';
|
||
submitBtn.disabled = true;
|
||
const txt = document.getElementById('your-albums-batch-submit-text');
|
||
if (txt) txt.textContent = 'Done';
|
||
}
|
||
showToast(`${totalAdded} tracks added to wishlist`, totalAdded > 0 ? 'success' : 'info');
|
||
} catch (e) {
|
||
console.error('Error adding your albums to wishlist:', e);
|
||
showToast(`Error: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
|
||
function _renderCompactTrackRow(track, index) {
|
||
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
|
||
const durationMin = Math.floor(track.duration_ms / 60000);
|
||
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
|
||
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||
return `
|
||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||
<div class="track-compact-number">${index + 1}</div>
|
||
<div class="track-compact-image">
|
||
<img src="${coverUrl}" alt="${track.album_name}" loading="lazy">
|
||
</div>
|
||
<div class="track-compact-info">
|
||
<div class="track-compact-name">${track.track_name}</div>
|
||
<div class="track-compact-artist">${track.artist_name}</div>
|
||
</div>
|
||
<div class="track-compact-album">${track.album_name}</div>
|
||
<div class="track-compact-duration">${duration}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
let _releaseRadarCtrl = null;
|
||
|
||
async function loadDiscoverReleaseRadar() {
|
||
if (!_releaseRadarCtrl) {
|
||
_releaseRadarCtrl = createDiscoverSectionController({
|
||
id: 'release-radar',
|
||
contentEl: '#release-radar-playlist',
|
||
fetchUrl: '/api/discover/release-radar',
|
||
extractItems: (data) => data.tracks || [],
|
||
renderItems: (items) => {
|
||
discoverReleaseRadarTracks = items;
|
||
const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join('');
|
||
return `<div class="discover-playlist-tracks-compact">${rows}</div>`;
|
||
},
|
||
loadingMessage: 'Loading release radar...',
|
||
emptyMessage: 'No new releases available',
|
||
errorMessage: 'Failed to load release radar',
|
||
verboseErrors: true,
|
||
showErrorToast: true,
|
||
});
|
||
}
|
||
return _releaseRadarCtrl.load();
|
||
}
|
||
|
||
let _weeklyCtrl = null;
|
||
|
||
async function loadDiscoverWeekly() {
|
||
if (!_weeklyCtrl) {
|
||
_weeklyCtrl = createDiscoverSectionController({
|
||
id: 'discovery-weekly',
|
||
contentEl: '#discovery-weekly-playlist',
|
||
fetchUrl: '/api/discover/weekly',
|
||
extractItems: (data) => data.tracks || [],
|
||
renderItems: (items) => {
|
||
discoverWeeklyTracks = items;
|
||
const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join('');
|
||
return `<div class="discover-playlist-tracks-compact">${rows}</div>`;
|
||
},
|
||
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 `
|
||
<div class="discover-card decade-card-modern" onclick="openDecadePlaylist(${decade.year})">
|
||
<div class="discover-card-image decade-card-image">
|
||
<div class="decade-icon-large">${icon}</div>
|
||
</div>
|
||
<div class="discover-card-info">
|
||
<h4 class="discover-card-title">${label}</h4>
|
||
<p class="discover-card-subtitle">${decade.track_count} tracks</p>
|
||
<p class="discover-card-meta">Classics</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 `
|
||
<div class="discover-card genre-card-modern" onclick="openGenrePlaylist('${escapeForInlineJs(genre.name)}')">
|
||
<div class="discover-card-image genre-card-image">
|
||
<div class="genre-icon-large">${icon}</div>
|
||
</div>
|
||
<div class="discover-card-info">
|
||
<h4 class="discover-card-title">${displayName}</h4>
|
||
<p class="discover-card-subtitle">${genre.track_count} tracks</p>
|
||
<p class="discover-card-meta">Curated</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 `
|
||
<div class="discover-sync-status" id="${idPrefix}-sync-status" style="display: none;">
|
||
<div class="sync-status-content">
|
||
<div class="sync-status-label">
|
||
<span class="sync-icon">⟳</span>
|
||
<span>Syncing to media server...</span>
|
||
</div>
|
||
<div class="sync-status-stats">
|
||
<span class="sync-stat">✓ <span id="${idPrefix}-sync-completed">0</span></span>
|
||
<span class="sync-stat">⏳ <span id="${idPrefix}-sync-pending">0</span></span>
|
||
<span class="sync-stat">✗ <span id="${idPrefix}-sync-failed">0</span></span>
|
||
<span class="sync-stat">(<span id="${idPrefix}-sync-percentage">0</span>%)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ===============================
|
||
// 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 = `<div class="discover-empty"><p>${cfg.emptyTabsMessage}</p></div>`;
|
||
return;
|
||
}
|
||
|
||
let tabsHTML = '';
|
||
let contentsHTML = '';
|
||
tabs.forEach((tab, index) => {
|
||
const isActive = index === 0;
|
||
tabsHTML += cfg.renderTabButton(tab, isActive);
|
||
contentsHTML += cfg.renderTabShell(tab, isActive);
|
||
});
|
||
|
||
tabsContainer.innerHTML = tabsHTML;
|
||
contentsContainer.innerHTML = contentsHTML;
|
||
|
||
if (typeof cfg.onTabsRendered === 'function') {
|
||
try { cfg.onTabsRendered(tabs); }
|
||
catch (err) { console.debug(`[${cfg.id}] onTabsRendered threw:`, err); }
|
||
}
|
||
|
||
// Phase 2: kick off content load for the first tab.
|
||
await loadTabContent(tabs[0]);
|
||
} catch (error) {
|
||
console.error(`Error loading ${cfg.id} tabs:`, error);
|
||
const tabsContainer = document.getElementById(cfg.tabsContainerId);
|
||
if (tabsContainer) {
|
||
tabsContainer.innerHTML = `<div class="discover-empty"><p>${cfg.errorTabsMessage}</p></div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadTabContent(tab) {
|
||
const tabId = cfg.tabId(tab);
|
||
const playlistContainer = document.getElementById(`${tabId}-playlist`);
|
||
if (!playlistContainer) return;
|
||
|
||
try {
|
||
const tracks = await cfg.fetchTabContent(tab);
|
||
if (!Array.isArray(tracks) || tracks.length === 0) {
|
||
const msg = (typeof cfg.emptyContentMessage === 'function')
|
||
? cfg.emptyContentMessage(tab)
|
||
: cfg.emptyContentMessage;
|
||
playlistContainer.innerHTML = `<div class="discover-empty"><p>${msg}</p></div>`;
|
||
return;
|
||
}
|
||
playlistContainer.innerHTML = cfg.renderTabTracks(tracks, tab);
|
||
} catch (error) {
|
||
console.error(`Error loading ${cfg.id} tab content:`, error);
|
||
const stillThere = document.getElementById(`${tabId}-playlist`);
|
||
if (stillThere) {
|
||
stillThere.innerHTML = `<div class="discover-empty"><p>${cfg.errorContentMessage}</p></div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
return { loadTabs, loadTabContent };
|
||
}
|
||
|
||
// ----- Decade browser config + thin wrappers -----
|
||
|
||
let _decadeBrowserTabsCtrl = null;
|
||
|
||
function _getDecadeBrowserTabsCtrl() {
|
||
if (_decadeBrowserTabsCtrl) return _decadeBrowserTabsCtrl;
|
||
_decadeBrowserTabsCtrl = createTabbedBrowserSection({
|
||
id: 'decade-browser-tabs',
|
||
tabsContainerId: 'decade-tabs',
|
||
contentsContainerId: 'decade-tab-contents',
|
||
fetchTabs: async () => {
|
||
const response = await fetch('/api/discover/decades/available');
|
||
if (!response.ok) throw new Error('Failed to fetch available decades');
|
||
const data = await response.json();
|
||
if (!data.success) return [];
|
||
return data.decades || [];
|
||
},
|
||
tabId: (decade) => `decade-${decade.year}`,
|
||
renderTabButton: (decade, isActive) => {
|
||
const icon = getDecadeIcon(decade.year);
|
||
return `
|
||
<button class="decade-tab ${isActive ? 'active' : ''}"
|
||
data-decade="${decade.year}"
|
||
onclick="switchDecadeTab(${decade.year})">
|
||
${icon} ${decade.year}s
|
||
</button>
|
||
`;
|
||
},
|
||
renderTabShell: (decade, isActive) => {
|
||
const tabId = `decade-${decade.year}`;
|
||
return `
|
||
<div class="decade-tab-content ${isActive ? 'active' : ''}" id="${tabId}-content">
|
||
<!-- Action Buttons -->
|
||
<div class="decade-actions">
|
||
<div class="discover-section-header">
|
||
<div>
|
||
<h3 style="margin: 0; color: #fff; font-size: 18px;">${decade.year}s Classics</h3>
|
||
<p id="${tabId}-subtitle" style="margin: 4px 0 0 0; color: #999; font-size: 13px;">${decade.track_count} tracks</p>
|
||
</div>
|
||
<div class="discover-section-actions">
|
||
<button class="action-button secondary" onclick="openDownloadModalForDecade(${decade.year})" title="Download missing tracks">
|
||
<span class="button-icon">↓</span>
|
||
<span class="button-text">Download</span>
|
||
</button>
|
||
<button class="action-button primary" id="${tabId}-sync-btn" onclick="startDecadeSync(${decade.year})" title="Sync to media server">
|
||
<span class="button-icon">⟳</span>
|
||
<span class="button-text">Sync</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
${_renderSyncStatusBlock(tabId)}
|
||
</div>
|
||
|
||
<!-- Track List -->
|
||
<div class="discover-playlist-container compact" id="${tabId}-playlist">
|
||
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading ${decade.year}s tracks...</p></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
},
|
||
fetchTabContent: async (decade) => {
|
||
const response = await fetch(`/api/discover/decade/${decade.year}`);
|
||
if (!response.ok) throw new Error('Failed to fetch decade playlist');
|
||
const data = await response.json();
|
||
if (!data.success) return [];
|
||
// Side-effect: cache + active marker, exactly as old code did.
|
||
decadeTracksCache[decade.year] = data.tracks || [];
|
||
activeDecade = decade.year;
|
||
return data.tracks || [];
|
||
},
|
||
renderTabTracks: (tracks) => _renderTabbedTrackList(tracks),
|
||
emptyContentMessage: (decade) => `No tracks found for the ${decade.year}s`,
|
||
errorTabsMessage: 'Failed to load decades',
|
||
errorContentMessage: 'Failed to load decade tracks',
|
||
emptyTabsMessage: 'No decade content available yet. Run a watchlist scan to populate your discovery pool!',
|
||
});
|
||
return _decadeBrowserTabsCtrl;
|
||
}
|
||
|
||
// Shared track-row markup for tabbed browsers. Decade + genre rows
|
||
// have the same shape — both pull from `track_data_json` first then
|
||
// fall back to top-level fields. Lifted so the helper-driven
|
||
// renderers don't each carry a copy.
|
||
function _renderTabbedTrackList(tracks) {
|
||
let html = '<div class="discover-playlist-tracks-compact">';
|
||
tracks.forEach((track, index) => {
|
||
let trackData = track;
|
||
if (track.track_data_json) {
|
||
trackData = track.track_data_json;
|
||
}
|
||
|
||
const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
|
||
const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
|
||
const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
|
||
const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
|
||
const durationMs = trackData.duration_ms || track.duration_ms || 0;
|
||
|
||
const durationMin = Math.floor(durationMs / 60000);
|
||
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||
|
||
html += `
|
||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||
<div class="track-compact-number">${index + 1}</div>
|
||
<div class="track-compact-image">
|
||
<img src="${coverUrl}" alt="${albumName}" loading="lazy">
|
||
</div>
|
||
<div class="track-compact-info">
|
||
<div class="track-compact-name">${trackName}</div>
|
||
<div class="track-compact-artist">${artistName}</div>
|
||
</div>
|
||
<div class="track-compact-album">${albumName}</div>
|
||
<div class="track-compact-duration">${duration}</div>
|
||
</div>
|
||
`;
|
||
});
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
async function loadDecadeBrowserTabs() {
|
||
return _getDecadeBrowserTabsCtrl().loadTabs();
|
||
}
|
||
|
||
function switchDecadeTab(decade) {
|
||
// Update tab buttons
|
||
const tabs = document.querySelectorAll('.decade-tab');
|
||
tabs.forEach(tab => {
|
||
if (parseInt(tab.getAttribute('data-decade')) === decade) {
|
||
tab.classList.add('active');
|
||
} else {
|
||
tab.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Update tab content
|
||
const tabContents = document.querySelectorAll('.decade-tab-content');
|
||
tabContents.forEach(content => {
|
||
if (content.id === `decade-${decade}-content`) {
|
||
content.classList.add('active');
|
||
} else {
|
||
content.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Load tracks if not already loaded
|
||
if (!decadeTracksCache[decade]) {
|
||
loadDecadeTracks(decade);
|
||
}
|
||
}
|
||
|
||
async function loadDecadeTracks(decade) {
|
||
return _getDecadeBrowserTabsCtrl().loadTabContent({ year: decade });
|
||
}
|
||
|
||
async function startDecadeSync(decade) {
|
||
const tracks = decadeTracksCache[decade];
|
||
if (!tracks || tracks.length === 0) {
|
||
showToast('No tracks available for this decade', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Convert to format expected by sync API
|
||
const spotifyTracks = tracks.map(track => {
|
||
// Extract track data from track_data_json if available
|
||
let trackData = track;
|
||
if (track.track_data_json) {
|
||
trackData = track.track_data_json;
|
||
}
|
||
|
||
// Build properly formatted Spotify track object
|
||
let spotifyTrack = {
|
||
id: trackData.id || track.spotify_track_id,
|
||
name: trackData.name || trackData.track_name || track.track_name,
|
||
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
|
||
album: trackData.album || {
|
||
name: trackData.album_name || track.album_name,
|
||
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
|
||
},
|
||
duration_ms: trackData.duration_ms || track.duration_ms || 0
|
||
};
|
||
|
||
// Normalize artists to array of strings for sync compatibility
|
||
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
|
||
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
|
||
}
|
||
|
||
return spotifyTrack;
|
||
});
|
||
|
||
const virtualPlaylistId = `discover_decade_${decade}`;
|
||
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
|
||
|
||
const virtualPlaylist = {
|
||
id: virtualPlaylistId,
|
||
name: `${decade}s Classics`,
|
||
track_count: spotifyTracks.length
|
||
};
|
||
|
||
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
|
||
spotifyPlaylists.push(virtualPlaylist);
|
||
}
|
||
|
||
// Show sync status display
|
||
const statusDisplay = document.getElementById(`decade-${decade}-sync-status`);
|
||
if (statusDisplay) statusDisplay.style.display = 'block';
|
||
|
||
// Disable sync button
|
||
const syncButton = document.getElementById(`decade-${decade}-sync-btn`);
|
||
if (syncButton) {
|
||
syncButton.disabled = true;
|
||
syncButton.style.opacity = '0.5';
|
||
syncButton.style.cursor = 'not-allowed';
|
||
}
|
||
|
||
// Start sync
|
||
await startPlaylistSync(virtualPlaylistId);
|
||
|
||
// Start polling
|
||
startDecadeSyncPolling(decade, virtualPlaylistId);
|
||
}
|
||
|
||
function startDecadeSyncPolling(decade, virtualPlaylistId) {
|
||
const pollerId = `decade_${decade}`;
|
||
|
||
if (discoverSyncPollers[pollerId]) {
|
||
clearInterval(discoverSyncPollers[pollerId]);
|
||
}
|
||
|
||
// Phase 5: Subscribe via WebSocket
|
||
if (socketConnected) {
|
||
socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] });
|
||
_syncProgressCallbacks[virtualPlaylistId] = (data) => {
|
||
const progress = data.progress || {};
|
||
const total = progress.total_tracks || 0;
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const processed = matched + failed;
|
||
const pending = total - processed;
|
||
const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
const el = (id) => document.getElementById(id);
|
||
if (el(`decade-${decade}-sync-completed`)) el(`decade-${decade}-sync-completed`).textContent = matched;
|
||
if (el(`decade-${decade}-sync-pending`)) el(`decade-${decade}-sync-pending`).textContent = pending;
|
||
if (el(`decade-${decade}-sync-failed`)) el(`decade-${decade}-sync-failed`).textContent = failed;
|
||
if (el(`decade-${decade}-sync-percentage`)) el(`decade-${decade}-sync-percentage`).textContent = pct;
|
||
if (data.status === 'finished') {
|
||
if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; }
|
||
socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] });
|
||
delete _syncProgressCallbacks[virtualPlaylistId];
|
||
const syncButton = el(`decade-${decade}-sync-btn`);
|
||
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
|
||
showToast(`${decade}s Classics sync complete!`, 'success');
|
||
setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
|
||
}
|
||
};
|
||
}
|
||
|
||
discoverSyncPollers[pollerId] = setInterval(async () => {
|
||
// Always poll — no dedicated WebSocket events for discovery progress
|
||
try {
|
||
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
const progress = data.progress || {};
|
||
|
||
const completedEl = document.getElementById(`decade-${decade}-sync-completed`);
|
||
const pendingEl = document.getElementById(`decade-${decade}-sync-pending`);
|
||
const failedEl = document.getElementById(`decade-${decade}-sync-failed`);
|
||
const percentageEl = document.getElementById(`decade-${decade}-sync-percentage`);
|
||
|
||
const total = progress.total_tracks || 0;
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const processed = matched + failed;
|
||
const pending = total - processed;
|
||
const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
|
||
if (completedEl) completedEl.textContent = matched;
|
||
if (pendingEl) pendingEl.textContent = pending;
|
||
if (failedEl) failedEl.textContent = failed;
|
||
if (percentageEl) percentageEl.textContent = completionPercentage;
|
||
|
||
if (data.status === 'finished') {
|
||
clearInterval(discoverSyncPollers[pollerId]);
|
||
delete discoverSyncPollers[pollerId];
|
||
|
||
const syncButton = document.getElementById(`decade-${decade}-sync-btn`);
|
||
if (syncButton) {
|
||
syncButton.disabled = false;
|
||
syncButton.style.opacity = '1';
|
||
syncButton.style.cursor = 'pointer';
|
||
}
|
||
|
||
showToast(`${decade}s Classics sync complete!`, 'success');
|
||
|
||
setTimeout(() => {
|
||
const statusDisplay = document.getElementById(`decade-${decade}-sync-status`);
|
||
if (statusDisplay) statusDisplay.style.display = 'none';
|
||
}, 3000);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error polling sync status for decade ${decade}:`, error);
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
async function openDownloadModalForDecade(decade) {
|
||
const tracks = decadeTracksCache[decade];
|
||
if (!tracks || tracks.length === 0) {
|
||
showToast('No tracks available for this decade', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Convert to format expected by download modal
|
||
const spotifyTracks = tracks.map(track => {
|
||
// Extract track data from track_data_json if available
|
||
let trackData = track;
|
||
if (track.track_data_json) {
|
||
trackData = track.track_data_json;
|
||
}
|
||
|
||
// Build properly formatted Spotify track object
|
||
let spotifyTrack = {
|
||
id: trackData.id || track.spotify_track_id,
|
||
name: trackData.name || trackData.track_name || track.track_name,
|
||
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
|
||
album: trackData.album || {
|
||
name: trackData.album_name || track.album_name,
|
||
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
|
||
},
|
||
duration_ms: trackData.duration_ms || track.duration_ms || 0
|
||
};
|
||
|
||
return spotifyTrack;
|
||
});
|
||
|
||
const playlistName = `${decade}s Classics`;
|
||
const virtualPlaylistId = `decade_${decade}`;
|
||
|
||
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
||
}
|
||
|
||
// ===============================
|
||
// BROWSE BY GENRE (TABBED BY GENRE)
|
||
// ===============================
|
||
|
||
let genreTracksCache = {}; // Store tracks for each genre
|
||
let activeGenre = null;
|
||
let availableGenres = [];
|
||
|
||
// Helper: derive the URL/DOM-safe id used for a genre tab. Both
|
||
// the tab shell and the playlist-container element are keyed on this.
|
||
function _genreTabId(genreName) {
|
||
const safe = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||
return `genre-${safe}`;
|
||
}
|
||
|
||
let _genreBrowserTabsCtrl = null;
|
||
|
||
function _getGenreBrowserTabsCtrl() {
|
||
if (_genreBrowserTabsCtrl) return _genreBrowserTabsCtrl;
|
||
_genreBrowserTabsCtrl = createTabbedBrowserSection({
|
||
id: 'genre-browser-tabs',
|
||
tabsContainerId: 'genre-tabs',
|
||
contentsContainerId: 'genre-tab-contents',
|
||
fetchTabs: async () => {
|
||
const response = await fetch('/api/discover/genres/available');
|
||
if (!response.ok) throw new Error('Failed to fetch available genres');
|
||
const data = await response.json();
|
||
if (!data.success) return [];
|
||
// Cap at 10 to avoid overcrowding; old behavior preserved.
|
||
return (data.genres || []).slice(0, 10);
|
||
},
|
||
onTabsRendered: (genres) => { availableGenres = genres; },
|
||
tabId: (genre) => _genreTabId(genre.name),
|
||
renderTabButton: (genre, isActive) => {
|
||
const icon = getGenreIcon(genre.name);
|
||
return `
|
||
<button class="genre-tab ${isActive ? 'active' : ''}"
|
||
data-genre="${escapeHtml(genre.name)}"
|
||
onclick="switchGenreTab('${escapeForInlineJs(genre.name)}')">
|
||
${icon} ${capitalizeGenre(genre.name)}
|
||
</button>
|
||
`;
|
||
},
|
||
renderTabShell: (genre, isActive) => {
|
||
const tabId = _genreTabId(genre.name);
|
||
return `
|
||
<div class="genre-tab-content ${isActive ? 'active' : ''}" id="${tabId}-content" data-genre="${escapeHtml(genre.name)}">
|
||
<!-- Action Buttons -->
|
||
<div class="genre-actions">
|
||
<div class="discover-section-header">
|
||
<div>
|
||
<h3 style="margin: 0; color: #fff; font-size: 18px;">${capitalizeGenre(genre.name)} Mix</h3>
|
||
<p id="${tabId}-subtitle" style="margin: 4px 0 0 0; color: #999; font-size: 13px;">${genre.track_count} tracks</p>
|
||
</div>
|
||
<div class="discover-section-actions">
|
||
<button class="action-button secondary" onclick="openDownloadModalForGenre('${escapeForInlineJs(genre.name)}')" title="Download missing tracks">
|
||
<span class="button-icon">↓</span>
|
||
<span class="button-text">Download</span>
|
||
</button>
|
||
<button class="action-button primary" id="${tabId}-sync-btn" onclick="startGenreSync('${escapeForInlineJs(genre.name)}')" title="Sync to media server">
|
||
<span class="button-icon">⟳</span>
|
||
<span class="button-text">Sync</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
${_renderSyncStatusBlock(tabId)}
|
||
</div>
|
||
|
||
<!-- Track List -->
|
||
<div class="discover-playlist-container compact" id="${tabId}-playlist">
|
||
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading ${capitalizeGenre(genre.name)} tracks...</p></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
},
|
||
fetchTabContent: async (genre) => {
|
||
const response = await fetch(`/api/discover/genre/${encodeURIComponent(genre.name)}`);
|
||
if (!response.ok) throw new Error('Failed to fetch genre playlist');
|
||
const data = await response.json();
|
||
if (!data.success) return [];
|
||
// Side-effect: cache + active marker, exactly as old code did.
|
||
genreTracksCache[genre.name] = data.tracks || [];
|
||
activeGenre = genre.name;
|
||
return data.tracks || [];
|
||
},
|
||
renderTabTracks: (tracks) => _renderTabbedTrackList(tracks),
|
||
emptyContentMessage: (genre) => `No tracks found for ${capitalizeGenre(genre.name)}`,
|
||
errorTabsMessage: 'Failed to load genres',
|
||
errorContentMessage: 'Failed to load genre tracks',
|
||
emptyTabsMessage: 'No genre content available yet. Run a watchlist scan to populate your discovery pool!',
|
||
});
|
||
return _genreBrowserTabsCtrl;
|
||
}
|
||
|
||
async function loadGenreBrowserTabs() {
|
||
return _getGenreBrowserTabsCtrl().loadTabs();
|
||
}
|
||
|
||
function switchGenreTab(genreName) {
|
||
// Update tab buttons
|
||
const tabs = document.querySelectorAll('.genre-tab');
|
||
tabs.forEach(tab => {
|
||
if (tab.getAttribute('data-genre') === genreName) {
|
||
tab.classList.add('active');
|
||
} else {
|
||
tab.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Update tab content
|
||
const tabContents = document.querySelectorAll('.genre-tab-content');
|
||
tabContents.forEach(content => {
|
||
if (content.getAttribute('data-genre') === genreName) {
|
||
content.classList.add('active');
|
||
} else {
|
||
content.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Load tracks if not already loaded
|
||
if (!genreTracksCache[genreName]) {
|
||
loadGenreTracks(genreName);
|
||
}
|
||
}
|
||
|
||
async function loadGenreTracks(genreName) {
|
||
return _getGenreBrowserTabsCtrl().loadTabContent({ name: genreName });
|
||
}
|
||
|
||
async function startGenreSync(genreName) {
|
||
const tracks = genreTracksCache[genreName];
|
||
if (!tracks || tracks.length === 0) {
|
||
showToast('No tracks available for this genre', 'warning');
|
||
return;
|
||
}
|
||
|
||
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||
|
||
// Convert to format expected by sync API
|
||
const spotifyTracks = tracks.map(track => {
|
||
// Extract track data from track_data_json if available
|
||
let trackData = track;
|
||
if (track.track_data_json) {
|
||
trackData = track.track_data_json;
|
||
}
|
||
|
||
// Build properly formatted Spotify track object
|
||
let spotifyTrack = {
|
||
id: trackData.id || track.spotify_track_id,
|
||
name: trackData.name || trackData.track_name || track.track_name,
|
||
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
|
||
album: trackData.album || {
|
||
name: trackData.album_name || track.album_name,
|
||
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
|
||
},
|
||
duration_ms: trackData.duration_ms || track.duration_ms || 0
|
||
};
|
||
|
||
// Normalize artists to array of strings for sync compatibility
|
||
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
|
||
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
|
||
}
|
||
|
||
return spotifyTrack;
|
||
});
|
||
|
||
const virtualPlaylistId = `discover_genre_${genreName.replace(/\s+/g, '_')}`;
|
||
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
|
||
|
||
const virtualPlaylist = {
|
||
id: virtualPlaylistId,
|
||
name: `${capitalizeGenre(genreName)} Mix`,
|
||
track_count: spotifyTracks.length
|
||
};
|
||
|
||
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
|
||
spotifyPlaylists.push(virtualPlaylist);
|
||
}
|
||
|
||
// Show sync status display
|
||
const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`);
|
||
if (statusDisplay) statusDisplay.style.display = 'block';
|
||
|
||
// Disable sync button
|
||
const syncButton = document.getElementById(`genre-${genreId}-sync-btn`);
|
||
if (syncButton) {
|
||
syncButton.disabled = true;
|
||
syncButton.style.opacity = '0.5';
|
||
syncButton.style.cursor = 'not-allowed';
|
||
}
|
||
|
||
// Start sync
|
||
await startPlaylistSync(virtualPlaylistId);
|
||
|
||
// Start polling
|
||
startGenreSyncPolling(genreName, genreId, virtualPlaylistId);
|
||
}
|
||
|
||
function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) {
|
||
const pollerId = `genre_${genreId}`;
|
||
|
||
if (discoverSyncPollers[pollerId]) {
|
||
clearInterval(discoverSyncPollers[pollerId]);
|
||
}
|
||
|
||
// Phase 5: Subscribe via WebSocket
|
||
if (socketConnected) {
|
||
socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] });
|
||
_syncProgressCallbacks[virtualPlaylistId] = (data) => {
|
||
const progress = data.progress || {};
|
||
const total = progress.total_tracks || 0;
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const processed = matched + failed;
|
||
const pending = total - processed;
|
||
const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
const el = (id) => document.getElementById(id);
|
||
if (el(`genre-${genreId}-sync-completed`)) el(`genre-${genreId}-sync-completed`).textContent = matched;
|
||
if (el(`genre-${genreId}-sync-pending`)) el(`genre-${genreId}-sync-pending`).textContent = pending;
|
||
if (el(`genre-${genreId}-sync-failed`)) el(`genre-${genreId}-sync-failed`).textContent = failed;
|
||
if (el(`genre-${genreId}-sync-percentage`)) el(`genre-${genreId}-sync-percentage`).textContent = pct;
|
||
if (data.status === 'finished') {
|
||
if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; }
|
||
socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] });
|
||
delete _syncProgressCallbacks[virtualPlaylistId];
|
||
const syncButton = el(`genre-${genreId}-sync-btn`);
|
||
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
|
||
showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success');
|
||
setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
|
||
}
|
||
};
|
||
}
|
||
|
||
discoverSyncPollers[pollerId] = setInterval(async () => {
|
||
// Always poll — no dedicated WebSocket events for discovery progress
|
||
try {
|
||
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
const progress = data.progress || {};
|
||
|
||
const completedEl = document.getElementById(`genre-${genreId}-sync-completed`);
|
||
const pendingEl = document.getElementById(`genre-${genreId}-sync-pending`);
|
||
const failedEl = document.getElementById(`genre-${genreId}-sync-failed`);
|
||
const percentageEl = document.getElementById(`genre-${genreId}-sync-percentage`);
|
||
|
||
const total = progress.total_tracks || 0;
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const processed = matched + failed;
|
||
const pending = total - processed;
|
||
const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
|
||
if (completedEl) completedEl.textContent = matched;
|
||
if (pendingEl) pendingEl.textContent = pending;
|
||
if (failedEl) failedEl.textContent = failed;
|
||
if (percentageEl) percentageEl.textContent = completionPercentage;
|
||
|
||
if (data.status === 'finished') {
|
||
clearInterval(discoverSyncPollers[pollerId]);
|
||
delete discoverSyncPollers[pollerId];
|
||
|
||
const syncButton = document.getElementById(`genre-${genreId}-sync-btn`);
|
||
if (syncButton) {
|
||
syncButton.disabled = false;
|
||
syncButton.style.opacity = '1';
|
||
syncButton.style.cursor = 'pointer';
|
||
}
|
||
|
||
showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success');
|
||
|
||
setTimeout(() => {
|
||
const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`);
|
||
if (statusDisplay) statusDisplay.style.display = 'none';
|
||
}, 3000);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error polling sync status for genre ${genreName}:`, error);
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
async function openDownloadModalForGenre(genreName) {
|
||
const tracks = genreTracksCache[genreName];
|
||
if (!tracks || tracks.length === 0) {
|
||
showToast('No tracks available for this genre', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Convert to format expected by download modal
|
||
const spotifyTracks = tracks.map(track => {
|
||
// Extract track data from track_data_json if available
|
||
let trackData = track;
|
||
if (track.track_data_json) {
|
||
trackData = track.track_data_json;
|
||
}
|
||
|
||
// Build properly formatted Spotify track object
|
||
let spotifyTrack = {
|
||
id: trackData.id || track.spotify_track_id,
|
||
name: trackData.name || trackData.track_name || track.track_name,
|
||
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
|
||
album: trackData.album || {
|
||
name: trackData.album_name || track.album_name,
|
||
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
|
||
},
|
||
duration_ms: trackData.duration_ms || track.duration_ms || 0
|
||
};
|
||
|
||
return spotifyTrack;
|
||
});
|
||
|
||
const playlistName = `${capitalizeGenre(genreName)} Mix`;
|
||
const virtualPlaylistId = `genre_${genreName.replace(/\s+/g, '_')}`;
|
||
|
||
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
||
}
|
||
|
||
// ===============================
|
||
// LISTENBRAINZ PLAYLISTS
|
||
// ===============================
|
||
|
||
let listenbrainzPlaylistsCache = {}; // Store playlists by type
|
||
let listenbrainzTracksCache = {}; // Store tracks for each playlist
|
||
let activeListenBrainzTab = 'recommendations'; // Track active tab
|
||
let activeListenBrainzSubTab = null; // Track active sub-tab within recommendations
|
||
|
||
// ── Last.fm Track Radio ──────────────────────────────────────────────────────
|
||
|
||
let _lastfmRadioDebounceTimer = null;
|
||
let _lastfmRadioSelected = null; // {name, artist}
|
||
|
||
function debouncedLastfmTrackSearch(query) {
|
||
clearTimeout(_lastfmRadioDebounceTimer);
|
||
const q = (query || '').trim();
|
||
if (!q) {
|
||
document.getElementById('lastfm-radio-dropdown').style.display = 'none';
|
||
return;
|
||
}
|
||
_lastfmRadioDebounceTimer = setTimeout(() => _runLastfmTrackSearch(q), 400);
|
||
}
|
||
|
||
async function _runLastfmTrackSearch(q) {
|
||
if (q.length < 2) return;
|
||
const dropdown = document.getElementById('lastfm-radio-dropdown');
|
||
// Show a mini spinner while fetching
|
||
dropdown.innerHTML = '<div class="lastfm-radio-searching"><div class="server-search-spinner" style="width:14px;height:14px;margin:0 auto;"></div></div>';
|
||
dropdown.style.display = 'block';
|
||
try {
|
||
const res = await fetch(`/api/lastfm/search/tracks?q=${encodeURIComponent(q)}`);
|
||
if (!res.ok) { dropdown.style.display = 'none'; return; }
|
||
const data = await res.json();
|
||
if (!data.results || data.results.length === 0) {
|
||
dropdown.style.display = 'none';
|
||
return;
|
||
}
|
||
dropdown.innerHTML = data.results.map(t => {
|
||
const imgHtml = t.image_url
|
||
? `<img src="${t.image_url}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'lastfm-radio-art-empty\\'></div>'">`
|
||
: '<div class="lastfm-radio-art-empty"></div>';
|
||
const listeners = t.listeners > 0
|
||
? `<span class="lastfm-radio-result-listeners">${(t.listeners / 1000).toFixed(0)}k listeners</span>`
|
||
: '';
|
||
return `
|
||
<div class="lastfm-radio-result" onclick="selectLastfmRadioTrack(decodeURIComponent('${encodeURIComponent(t.name)}'), decodeURIComponent('${encodeURIComponent(t.artist)}'))">
|
||
<div class="lastfm-radio-result-art">${imgHtml}</div>
|
||
<div class="lastfm-radio-result-meta">
|
||
<span class="lastfm-radio-result-track">${t.name}</span>
|
||
<span class="lastfm-radio-result-artist">${t.artist}${listeners ? ' · ' + t.listeners.toLocaleString() + ' listeners' : ''}</span>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
dropdown.style.display = 'block';
|
||
} catch (e) {
|
||
console.error('Last.fm search error:', e);
|
||
dropdown.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function selectLastfmRadioTrack(name, artist) {
|
||
// Close dropdown and update input to show selection
|
||
document.getElementById('lastfm-radio-dropdown').style.display = 'none';
|
||
document.getElementById('lastfm-radio-input').value = `${name} — ${artist}`;
|
||
document.getElementById('lastfm-radio-input').blur();
|
||
// Immediately kick off generation
|
||
_generateLastfmRadioFor(name, artist);
|
||
}
|
||
|
||
function clearLastfmRadioSelection() {
|
||
document.getElementById('lastfm-radio-input').value = '';
|
||
document.getElementById('lastfm-radio-dropdown').style.display = 'none';
|
||
}
|
||
|
||
// Keep generateLastfmRadio as public alias (called by nothing now but harmless)
|
||
async function generateLastfmRadio() {
|
||
const input = (document.getElementById('lastfm-radio-input').value || '').trim();
|
||
if (!input) return;
|
||
// Parse "Track — Artist" format if present
|
||
const parts = input.split(' — ');
|
||
if (parts.length >= 2) {
|
||
await _generateLastfmRadioFor(parts[0].trim(), parts[1].trim());
|
||
}
|
||
}
|
||
|
||
async function _generateLastfmRadioFor(name, artist) {
|
||
const container = document.getElementById('lastfm-radio-playlists');
|
||
const input = document.getElementById('lastfm-radio-input');
|
||
|
||
// Show loading state in the playlists area
|
||
if (container) {
|
||
container.innerHTML = `
|
||
<div class="lastfm-radio-generating">
|
||
<div class="server-search-spinner"></div>
|
||
<p>Building radio for <strong>${name}</strong> by <strong>${artist}</strong>…</p>
|
||
</div>`;
|
||
}
|
||
if (input) input.disabled = true;
|
||
|
||
try {
|
||
const res = await fetch('/api/lastfm/radio/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ track_name: name, artist_name: artist }),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
if (container) container.innerHTML = '';
|
||
showToast(data.error || 'Failed to generate radio', 'error');
|
||
return;
|
||
}
|
||
// Reload all radio playlist cards
|
||
await _loadLastfmRadioPlaylists();
|
||
} catch (e) {
|
||
if (container) container.innerHTML = '';
|
||
showToast('Error generating Last.fm radio', 'error');
|
||
console.error(e);
|
||
} finally {
|
||
if (input) input.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function initializeLastfmRadioSection() {
|
||
try {
|
||
const cfgRes = await fetch('/api/lastfm/configured');
|
||
if (!cfgRes.ok) return;
|
||
const { configured } = await cfgRes.json();
|
||
const section = document.getElementById('lastfm-radio-section');
|
||
if (!section) return;
|
||
if (!configured) {
|
||
section.style.display = 'none';
|
||
return;
|
||
}
|
||
section.style.display = '';
|
||
await _loadLastfmRadioPlaylists();
|
||
} catch (e) {
|
||
console.error('Error initializing Last.fm Radio section:', e);
|
||
}
|
||
}
|
||
|
||
async function _loadLastfmRadioPlaylists() {
|
||
const container = document.getElementById('lastfm-radio-playlists');
|
||
if (!container) return;
|
||
try {
|
||
const res = await fetch('/api/discover/listenbrainz/lastfm-radio');
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
if (!data.success || !data.playlists || data.playlists.length === 0) {
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
// Reuse the same LB playlist card builder — cards are identical
|
||
container.innerHTML = buildListenBrainzPlaylistsHtml(data.playlists, 'lastfm_radio');
|
||
loadTracksForPlaylists(data.playlists);
|
||
} catch (e) {
|
||
console.error('Error loading Last.fm radio playlists:', e);
|
||
}
|
||
}
|
||
|
||
// Close dropdown when clicking outside
|
||
document.addEventListener('click', (e) => {
|
||
const section = document.getElementById('lastfm-radio-search-section');
|
||
if (section && !section.contains(e.target)) {
|
||
const dd = document.getElementById('lastfm-radio-dropdown');
|
||
if (dd) dd.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
async function initializeListenBrainzTabs() {
|
||
try {
|
||
console.log('🧠 Initializing ListenBrainz tabs...');
|
||
|
||
// Fetch all playlist types
|
||
const [createdForRes, userPlaylistsRes, collaborativeRes] = await Promise.all([
|
||
fetch('/api/discover/listenbrainz/created-for'),
|
||
fetch('/api/discover/listenbrainz/user-playlists'),
|
||
fetch('/api/discover/listenbrainz/collaborative'),
|
||
]);
|
||
|
||
console.log('📡 API Responses:', {
|
||
createdFor: createdForRes.status,
|
||
userPlaylists: userPlaylistsRes.status,
|
||
collaborative: collaborativeRes.status,
|
||
});
|
||
|
||
const tabs = [
|
||
{ id: 'recommendations', label: '🎁 Recommendations', hasData: false },
|
||
{ id: 'user', label: '📚 Your Playlists', hasData: false },
|
||
{ id: 'collaborative', label: '🤝 Collaborative', hasData: false },
|
||
];
|
||
|
||
// Track LB username for header display
|
||
let lbUsername = null;
|
||
|
||
// Check which tabs have data
|
||
if (createdForRes.ok) {
|
||
const data = await createdForRes.json();
|
||
console.log('📋 Created For data:', data);
|
||
if (data.username) lbUsername = data.username;
|
||
if (data.success && data.playlists && data.playlists.length > 0) {
|
||
listenbrainzPlaylistsCache['recommendations'] = data.playlists;
|
||
tabs[0].hasData = true;
|
||
console.log(`✅ Found ${data.playlists.length} recommendation playlists`);
|
||
}
|
||
}
|
||
|
||
if (userPlaylistsRes.ok) {
|
||
const data = await userPlaylistsRes.json();
|
||
console.log('📚 User Playlists data:', data);
|
||
if (data.username && !lbUsername) lbUsername = data.username;
|
||
if (data.success && data.playlists && data.playlists.length > 0) {
|
||
listenbrainzPlaylistsCache['user'] = data.playlists;
|
||
tabs[1].hasData = true;
|
||
console.log(`✅ Found ${data.playlists.length} user playlists`);
|
||
}
|
||
}
|
||
|
||
if (collaborativeRes.ok) {
|
||
const data = await collaborativeRes.json();
|
||
console.log('🤝 Collaborative data:', data);
|
||
if (data.username && !lbUsername) lbUsername = data.username;
|
||
if (data.success && data.playlists && data.playlists.length > 0) {
|
||
listenbrainzPlaylistsCache['collaborative'] = data.playlists;
|
||
tabs[2].hasData = true;
|
||
console.log(`✅ Found ${data.playlists.length} collaborative playlists`);
|
||
}
|
||
}
|
||
|
||
// Build tabs HTML
|
||
const tabsContainer = document.getElementById('listenbrainz-tabs');
|
||
console.log('🔧 Building tabs. Available tabs:', tabs.filter(t => t.hasData).map(t => t.label));
|
||
|
||
let tabsHtml = '<div class="decade-tabs-inner">'; // Reuse decade tabs styling
|
||
|
||
tabs.forEach(tab => {
|
||
if (tab.hasData) {
|
||
const isActive = tab.id === activeListenBrainzTab;
|
||
tabsHtml += `
|
||
<button class="decade-tab${isActive ? ' active' : ''}"
|
||
onclick="switchListenBrainzTab('${tab.id}')"
|
||
data-tab="${tab.id}">
|
||
${tab.label}
|
||
</button>
|
||
`;
|
||
}
|
||
});
|
||
tabsHtml += '</div>';
|
||
|
||
if (tabs.every(t => !t.hasData)) {
|
||
console.log('⚠️ No tabs have data');
|
||
tabsContainer.innerHTML = `
|
||
<div class="lb-empty-state">
|
||
<div class="lb-empty-icon">🧠</div>
|
||
<h3>Connect ListenBrainz</h3>
|
||
<p>Link your ListenBrainz account to see personalized playlists, recommendations, and collaborative playlists.</p>
|
||
<button class="action-button primary lb-connect-btn" onclick="openPersonalSettings()">
|
||
Connect ListenBrainz
|
||
</button>
|
||
<p class="lb-empty-help">Get your token from <a href="https://listenbrainz.org/profile/" target="_blank">listenbrainz.org/profile</a></p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
tabsContainer.innerHTML = tabsHtml;
|
||
|
||
// Update section subtitle with username
|
||
const lbSubtitle = document.getElementById('listenbrainz-section-subtitle');
|
||
if (lbSubtitle) {
|
||
lbSubtitle.textContent = lbUsername ? `Playlists for ${lbUsername}` : 'Playlists from ListenBrainz';
|
||
}
|
||
|
||
// Load first available tab
|
||
const firstTab = tabs.find(t => t.hasData);
|
||
if (firstTab) {
|
||
console.log(`🎯 Loading first tab: ${firstTab.label} (${firstTab.id})`);
|
||
activeListenBrainzTab = firstTab.id;
|
||
loadListenBrainzTabContent(firstTab.id);
|
||
} else {
|
||
console.log('❌ No first tab found');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error initializing ListenBrainz tabs:', error);
|
||
const tabsContainer = document.getElementById('listenbrainz-tabs');
|
||
if (tabsContainer) {
|
||
tabsContainer.innerHTML = '<div class="discover-empty"><p>Failed to load playlists</p></div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
function switchListenBrainzTab(tabId) {
|
||
// Update active tab
|
||
activeListenBrainzTab = tabId;
|
||
|
||
// Update tab buttons
|
||
const tabs = document.querySelectorAll('#listenbrainz-tabs .decade-tab');
|
||
tabs.forEach(tab => {
|
||
if (tab.dataset.tab === tabId) {
|
||
tab.classList.add('active');
|
||
} else {
|
||
tab.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Load content
|
||
loadListenBrainzTabContent(tabId);
|
||
}
|
||
|
||
function groupListenBrainzPlaylists(playlists) {
|
||
const groups = {};
|
||
const groupOrder = [];
|
||
|
||
playlists.forEach(playlist => {
|
||
const playlistData = playlist.playlist || playlist;
|
||
const title = (playlistData.title || '').toLowerCase();
|
||
|
||
let groupName;
|
||
if (title.includes('weekly jams')) {
|
||
groupName = 'Weekly Jams';
|
||
} else if (title.includes('weekly exploration')) {
|
||
groupName = 'Weekly Exploration';
|
||
} else if (title.includes('top discoveries')) {
|
||
groupName = 'Top Discoveries';
|
||
} else if (title.includes('top missed recordings')) {
|
||
groupName = 'Top Missed Recordings';
|
||
} else if (title.includes('daily jams')) {
|
||
groupName = 'Daily Jams';
|
||
} else {
|
||
groupName = 'Other';
|
||
}
|
||
|
||
if (!groups[groupName]) {
|
||
groups[groupName] = [];
|
||
groupOrder.push(groupName);
|
||
}
|
||
groups[groupName].push(playlist);
|
||
});
|
||
|
||
// Move "Other" to the end if it exists
|
||
const otherIdx = groupOrder.indexOf('Other');
|
||
if (otherIdx !== -1 && otherIdx !== groupOrder.length - 1) {
|
||
groupOrder.splice(otherIdx, 1);
|
||
groupOrder.push('Other');
|
||
}
|
||
|
||
return { groups, groupOrder };
|
||
}
|
||
|
||
function buildListenBrainzPlaylistsHtml(playlists, tabId) {
|
||
let html = '';
|
||
playlists.forEach((playlist, index) => {
|
||
const playlistData = playlist.playlist || playlist;
|
||
const identifier = playlistData.identifier?.split('/').pop() || '';
|
||
console.log(`📋 Playlist ${index}:`, {
|
||
title: playlistData.title,
|
||
fullIdentifier: playlistData.identifier,
|
||
extractedIdentifier: identifier
|
||
});
|
||
const title = playlistData.title || 'Untitled Playlist';
|
||
const creator = playlistData.creator || 'ListenBrainz';
|
||
|
||
let trackCount = 50;
|
||
if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) {
|
||
trackCount = playlistData.annotation.track_count;
|
||
} else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) {
|
||
trackCount = playlistData.track.length;
|
||
}
|
||
|
||
const playlistId = `discover-lb-playlist-${identifier}`; // Use consistent MBID-based ID
|
||
const virtualPlaylistId = `discover_lb_${tabId}_${identifier}`;
|
||
|
||
html += `
|
||
<div class="discover-section-subsection">
|
||
<div class="discover-section-header">
|
||
<div>
|
||
<h3 class="discover-section-subtitle-large">${title}</h3>
|
||
<p class="discover-section-meta" id="${playlistId}-meta">by ${creator} • Loading tracks...</p>
|
||
</div>
|
||
<div class="discover-section-actions">
|
||
<button class="action-button secondary"
|
||
onclick="openDownloadModalForListenBrainzPlaylist('${identifier}', '${escapeForInlineJs(title)}')"
|
||
title="Download missing tracks">
|
||
<span class="button-icon">↓</span>
|
||
<span class="button-text">Download</span>
|
||
</button>
|
||
<span class="wing-it-wrap">
|
||
<button class="action-button wing-it-btn-sm"
|
||
onclick="_toggleWingItDropdownLB(this, '${identifier}', '${escapeForInlineJs(title)}')"
|
||
title="Download or sync using raw track names — no metadata discovery">
|
||
<span class="button-icon">⚡</span>
|
||
<span class="button-text">Wing It</span>
|
||
</button>
|
||
</span>
|
||
<button class="action-button primary"
|
||
id="${playlistId}-sync-btn"
|
||
onclick="startListenBrainzPlaylistSync('${identifier}')"
|
||
title="Sync to media server"
|
||
style="display: none;">
|
||
<span class="button-icon">⟳</span>
|
||
<span class="button-text">Sync</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- Sync Status Display -->
|
||
<div class="discover-sync-status" id="${playlistId}-sync-status" style="display: none;">
|
||
<div class="sync-status-content">
|
||
<div class="sync-status-label">
|
||
<span class="sync-icon">⟳</span>
|
||
<span>Syncing to media server...</span>
|
||
</div>
|
||
<div class="sync-status-stats">
|
||
<span class="sync-stat">♪ <span id="${playlistId}-sync-total">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat">✓ <span id="${playlistId}-sync-matched">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat">✗ <span id="${playlistId}-sync-failed">0</span></span>
|
||
<span class="sync-stat">(<span id="${playlistId}-sync-percentage">0</span>%)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="discover-playlist-container compact" id="${playlistId}-playlist">
|
||
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading tracks...</p></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
return html;
|
||
}
|
||
|
||
function loadTracksForPlaylists(playlists) {
|
||
playlists.forEach((playlist) => {
|
||
const playlistData = playlist.playlist || playlist;
|
||
const identifier = playlistData.identifier?.split('/').pop() || '';
|
||
const playlistId = `discover-lb-playlist-${identifier}`;
|
||
loadListenBrainzPlaylistTracks(identifier, playlistId);
|
||
});
|
||
}
|
||
|
||
function switchListenBrainzSubTab(groupId) {
|
||
activeListenBrainzSubTab = groupId;
|
||
|
||
// Update sub-tab buttons
|
||
const subTabs = document.querySelectorAll('#lb-subtabs-bar .lb-subtab');
|
||
subTabs.forEach(tab => {
|
||
if (tab.dataset.group === groupId) {
|
||
tab.classList.add('active');
|
||
} else {
|
||
tab.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Show/hide sub-tab content panels
|
||
const panels = document.querySelectorAll('.lb-subtab-panel');
|
||
panels.forEach(panel => {
|
||
if (panel.dataset.group === groupId) {
|
||
panel.style.display = 'block';
|
||
// Load tracks for playlists in this panel if not already loaded
|
||
const unloaded = panel.querySelectorAll('.discover-loading');
|
||
if (unloaded.length > 0) {
|
||
const groupPlaylists = panel._playlists;
|
||
if (groupPlaylists) {
|
||
loadTracksForPlaylists(groupPlaylists);
|
||
}
|
||
}
|
||
} else {
|
||
panel.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadListenBrainzTabContent(tabId) {
|
||
const container = document.getElementById('listenbrainz-tab-content');
|
||
if (!container) return;
|
||
|
||
const playlists = listenbrainzPlaylistsCache[tabId] || [];
|
||
if (playlists.length === 0) {
|
||
container.innerHTML = '<div class="discover-empty"><p>No playlists in this category</p></div>';
|
||
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 = '<div class="decade-tabs-inner" id="lb-subtabs-bar" style="margin-bottom: 16px;">';
|
||
groupOrder.forEach(groupName => {
|
||
const isActive = groupName === firstGroup;
|
||
const count = groups[groupName].length;
|
||
subTabsHtml += `
|
||
<button class="decade-tab lb-subtab${isActive ? ' active' : ''}"
|
||
onclick="switchListenBrainzSubTab('${groupName}')"
|
||
data-group="${groupName}"
|
||
style="font-size: 13px; padding: 8px 16px;">
|
||
${groupName} (${count})
|
||
</button>
|
||
`;
|
||
});
|
||
subTabsHtml += '</div>';
|
||
|
||
// Build content panels for each group
|
||
let panelsHtml = '';
|
||
groupOrder.forEach(groupName => {
|
||
const isActive = groupName === firstGroup;
|
||
panelsHtml += `<div class="lb-subtab-panel" data-group="${groupName}" style="display: ${isActive ? 'block' : 'none'};">`;
|
||
panelsHtml += buildListenBrainzPlaylistsHtml(groups[groupName], tabId);
|
||
panelsHtml += '</div>';
|
||
});
|
||
|
||
container.innerHTML = subTabsHtml + panelsHtml;
|
||
|
||
// Store playlist references on panels for lazy loading
|
||
groupOrder.forEach(groupName => {
|
||
const panel = container.querySelector(`.lb-subtab-panel[data-group="${groupName}"]`);
|
||
if (panel) {
|
||
panel._playlists = groups[groupName];
|
||
}
|
||
});
|
||
|
||
// Load tracks only for the active sub-tab
|
||
loadTracksForPlaylists(groups[firstGroup]);
|
||
return;
|
||
}
|
||
|
||
// Default: flat list for user/collaborative tabs (or single-group recommendations)
|
||
const html = buildListenBrainzPlaylistsHtml(playlists, tabId);
|
||
container.innerHTML = html;
|
||
loadTracksForPlaylists(playlists);
|
||
}
|
||
|
||
async function loadListenBrainzPlaylistTracks(identifier, playlistId) {
|
||
try {
|
||
const playlistContainer = document.getElementById(`${playlistId}-playlist`);
|
||
if (!playlistContainer) return;
|
||
|
||
// Check cache first
|
||
if (listenbrainzTracksCache[identifier]) {
|
||
displayListenBrainzTracks(listenbrainzTracksCache[identifier], playlistId);
|
||
return;
|
||
}
|
||
|
||
console.log(`🔄 Fetching tracks for playlist: ${identifier}`);
|
||
const response = await fetch(`/api/discover/listenbrainz/playlist/${identifier}`);
|
||
console.log(`📡 Response status: ${response.status}`);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
console.error(`❌ Failed to fetch playlist: ${response.status} - ${errorText}`);
|
||
throw new Error('Failed to fetch playlist tracks');
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log(`📋 Received data:`, data);
|
||
console.log(`📊 Tracks count: ${data.tracks?.length || 0}`);
|
||
|
||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||
playlistContainer.innerHTML = '<div class="discover-empty"><p>No tracks available</p></div>';
|
||
return;
|
||
}
|
||
|
||
// Cache the tracks
|
||
listenbrainzTracksCache[identifier] = data.tracks;
|
||
|
||
// Display tracks
|
||
displayListenBrainzTracks(data.tracks, playlistId);
|
||
|
||
} catch (error) {
|
||
console.error('Error loading ListenBrainz playlist tracks:', error);
|
||
const playlistContainer = document.getElementById(`${playlistId}-playlist`);
|
||
if (playlistContainer) {
|
||
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load tracks</p></div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clean artist name by removing featured artists
|
||
* e.g., "Blackstreet feat. Dr. Dre & Queen Pen" -> "Blackstreet"
|
||
*/
|
||
function cleanArtistName(artistName) {
|
||
if (!artistName) return artistName;
|
||
|
||
// Remove everything after common featuring patterns (case insensitive)
|
||
const patterns = [
|
||
/\s+feat\.?\s+.*/i, // "feat." or "feat"
|
||
/\s+featuring\s+.*/i, // "featuring"
|
||
/\s+ft\.?\s+.*/i, // "ft." or "ft"
|
||
/\s+with\s+.*/i, // "with"
|
||
/\s+x\s+.*/i // " x " (common in collaborations)
|
||
];
|
||
|
||
let cleaned = artistName;
|
||
for (const pattern of patterns) {
|
||
cleaned = cleaned.replace(pattern, '');
|
||
}
|
||
|
||
return cleaned.trim();
|
||
}
|
||
|
||
function displayListenBrainzTracks(tracks, playlistId) {
|
||
const playlistContainer = document.getElementById(`${playlistId}-playlist`);
|
||
if (!playlistContainer) return;
|
||
|
||
console.log(`🎨 Displaying ${tracks.length} tracks for ${playlistId}`);
|
||
if (tracks.length > 0) {
|
||
console.log('Sample track data:', tracks[0]);
|
||
}
|
||
|
||
// Update track count in the metadata section
|
||
const metaElement = document.getElementById(`${playlistId}-meta`);
|
||
if (metaElement) {
|
||
// Extract creator from existing text (before the bullet)
|
||
const currentText = metaElement.textContent;
|
||
const creatorMatch = currentText.match(/by (.+?) •/);
|
||
const creator = creatorMatch ? creatorMatch[1] : 'ListenBrainz';
|
||
metaElement.textContent = `by ${creator} • ${tracks.length} track${tracks.length !== 1 ? 's' : ''}`;
|
||
}
|
||
|
||
// Simple SVG placeholder for missing album art (music note icon)
|
||
const placeholderImage = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiMyYTJhMmEiLz48cGF0aCBkPSJNMjQgMTJ2MTIuNUEzLjUgMy41IDAgMSAxIDIwLjUgMjFWMTZsLTUgMXY5YTMuNSAzLjUgMCAxIDEtMy41LTMuNVYxM2wxMi0zeiIgZmlsbD0iIzU1NSIvPjwvc3ZnPg==';
|
||
|
||
let html = '<div class="discover-playlist-tracks-compact">';
|
||
tracks.forEach((track, index) => {
|
||
const coverUrl = track.album_cover_url || placeholderImage;
|
||
const durationMin = Math.floor(track.duration_ms / 60000);
|
||
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
|
||
const duration = track.duration_ms > 0 ? `${durationMin}:${durationSec.toString().padStart(2, '0')}` : '';
|
||
|
||
const albumName = track.album_name ? escapeHtml(track.album_name) : '';
|
||
|
||
html += `
|
||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||
<div class="track-compact-number">${index + 1}</div>
|
||
<div class="track-compact-image">
|
||
<img src="${coverUrl}" alt="${albumName}" loading="lazy" onerror="this.src='${placeholderImage}'">
|
||
</div>
|
||
<div class="track-compact-info">
|
||
<div class="track-compact-name">${escapeHtml(track.track_name || 'Unknown Track')}</div>
|
||
<div class="track-compact-artist">${escapeHtml(cleanArtistName(track.artist_name) || 'Unknown Artist')}</div>
|
||
</div>
|
||
<div class="track-compact-album">${albumName}</div>
|
||
<div class="track-compact-duration">${duration}</div>
|
||
</div>
|
||
`;
|
||
});
|
||
html += '</div>';
|
||
|
||
playlistContainer.innerHTML = html;
|
||
}
|
||
|
||
function _toggleWingItDropdownLB(btn, identifier, title) {
|
||
const existing = document.querySelector('.wing-it-dropdown.visible');
|
||
if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; }
|
||
|
||
const wrap = btn.closest('.wing-it-wrap');
|
||
if (!wrap) return;
|
||
|
||
const dropdown = document.createElement('div');
|
||
dropdown.className = 'wing-it-dropdown';
|
||
dropdown.innerHTML = `
|
||
<button class="wing-it-dropdown-item" data-action="download">
|
||
<span class="wing-it-dropdown-icon">⬇️</span>
|
||
<span class="wing-it-dropdown-label">Download</span>
|
||
<span class="wing-it-dropdown-hint">Raw names</span>
|
||
</button>
|
||
<button class="wing-it-dropdown-item" data-action="sync">
|
||
<span class="wing-it-dropdown-icon">🔄</span>
|
||
<span class="wing-it-dropdown-label">Sync to Server</span>
|
||
<span class="wing-it-dropdown-hint">Best-effort</span>
|
||
</button>
|
||
`;
|
||
|
||
dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
dropdown.classList.remove('visible');
|
||
setTimeout(() => dropdown.remove(), 150);
|
||
const tracks = listenbrainzTracksCache[identifier];
|
||
if (!tracks || tracks.length === 0) {
|
||
showToast('No tracks cached. Try opening the playlist first.', 'error');
|
||
return;
|
||
}
|
||
if (item.dataset.action === 'download') {
|
||
wingItDownload(tracks, title, 'ListenBrainz', identifier, true);
|
||
} else {
|
||
_wingItSync(tracks, title, 'ListenBrainz', identifier);
|
||
}
|
||
});
|
||
});
|
||
|
||
const btnRect2 = btn.getBoundingClientRect();
|
||
if (btnRect2.top < 200) dropdown.classList.add('flip-down');
|
||
|
||
wrap.appendChild(dropdown);
|
||
requestAnimationFrame(() => dropdown.classList.add('visible'));
|
||
|
||
setTimeout(() => {
|
||
const closeHandler = e => {
|
||
if (!dropdown.contains(e.target) && e.target !== btn) {
|
||
dropdown.classList.remove('visible');
|
||
setTimeout(() => dropdown.remove(), 150);
|
||
document.removeEventListener('click', closeHandler);
|
||
}
|
||
};
|
||
document.addEventListener('click', closeHandler);
|
||
}, 50);
|
||
}
|
||
|
||
async function _wingItFromLBCard(identifier, title) {
|
||
// Legacy — kept for backward compat
|
||
const tracks = listenbrainzTracksCache[identifier];
|
||
if (!tracks || tracks.length === 0) {
|
||
showToast('No tracks cached for this playlist. Try opening the discovery modal first.', 'error');
|
||
return;
|
||
}
|
||
wingItDownload(tracks, title, 'ListenBrainz', identifier);
|
||
}
|
||
|
||
async function openDownloadModalForListenBrainzPlaylist(identifier, title) {
|
||
try {
|
||
const tracks = listenbrainzTracksCache[identifier];
|
||
if (!tracks || tracks.length === 0) {
|
||
showToast('No tracks to download', 'error');
|
||
return;
|
||
}
|
||
|
||
console.log(`🎵 Opening ListenBrainz discovery modal: ${title}`);
|
||
console.log(`🔍 Looking for existing state with identifier: ${identifier}`);
|
||
console.log(`📋 All ListenBrainz states:`, Object.keys(listenbrainzPlaylistStates));
|
||
|
||
// Check if state already exists from backend hydration (like Beatport does)
|
||
const existingState = listenbrainzPlaylistStates[identifier];
|
||
console.log(`🔍 Existing state found:`, existingState ? `Phase: ${existingState.phase}` : 'None');
|
||
|
||
if (existingState && existingState.phase !== 'fresh') {
|
||
// State exists - rehydrate the modal with existing data
|
||
console.log(`🔄 Rehydrating existing ListenBrainz state (Phase: ${existingState.phase})`);
|
||
|
||
// If downloading/download_complete, rehydrate download modal instead
|
||
if ((existingState.phase === 'downloading' || existingState.phase === 'download_complete') &&
|
||
existingState.convertedSpotifyPlaylistId && existingState.download_process_id) {
|
||
|
||
console.log(`📥 Rehydrating download modal for ListenBrainz playlist: ${title}`);
|
||
|
||
// Implement download modal rehydration (like Beatport does)
|
||
const convertedPlaylistId = existingState.convertedSpotifyPlaylistId;
|
||
|
||
try {
|
||
// Check if modal already exists (user just closed it)
|
||
if (activeDownloadProcesses[convertedPlaylistId]) {
|
||
console.log(`✅ Download modal already exists, just showing it`);
|
||
const process = activeDownloadProcesses[convertedPlaylistId];
|
||
if (process.modalElement) {
|
||
process.modalElement.style.display = 'flex';
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Create the download modal using the ListenBrainz state
|
||
console.log(`🆕 Creating new download modal for rehydration`);
|
||
// Get tracks from the existing state
|
||
let spotifyTracks = [];
|
||
|
||
if (existingState && existingState.discovery_results) {
|
||
spotifyTracks = existingState.discovery_results
|
||
.filter(result => result.spotify_data)
|
||
.map(result => {
|
||
const track = result.spotify_data;
|
||
// Ensure artists is an array of strings
|
||
if (track.artists && Array.isArray(track.artists)) {
|
||
track.artists = track.artists.map(artist =>
|
||
typeof artist === 'string' ? artist : (artist.name || artist)
|
||
);
|
||
} else if (track.artists && typeof track.artists === 'string') {
|
||
track.artists = [track.artists];
|
||
} else {
|
||
track.artists = ['Unknown Artist'];
|
||
}
|
||
return {
|
||
id: track.id,
|
||
name: track.name,
|
||
artists: track.artists,
|
||
album: track.album || 'Unknown Album',
|
||
duration_ms: track.duration_ms || 0,
|
||
external_urls: track.external_urls || {}
|
||
};
|
||
});
|
||
}
|
||
|
||
if (spotifyTracks.length > 0) {
|
||
await openDownloadMissingModalForYouTube(
|
||
convertedPlaylistId,
|
||
title,
|
||
spotifyTracks
|
||
);
|
||
|
||
// Set the modal to running state with the correct batch ID
|
||
const process = activeDownloadProcesses[convertedPlaylistId];
|
||
if (process) {
|
||
process.status = existingState.phase === 'download_complete' ? 'complete' : 'running';
|
||
process.batchId = existingState.download_process_id;
|
||
|
||
// Update UI to running state
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Start polling for this process
|
||
startModalDownloadPolling(convertedPlaylistId);
|
||
|
||
// Add to discover download sidebar if this has discoverMetadata
|
||
if (process.discoverMetadata) {
|
||
const playlistName = title;
|
||
const imageUrl = process.discoverMetadata.imageUrl;
|
||
const type = process.discoverMetadata.type || 'album';
|
||
addDiscoverDownload(convertedPlaylistId, playlistName, type, imageUrl);
|
||
console.log(`📥 [REHYDRATION] Added ListenBrainz download to sidebar: ${playlistName}`);
|
||
}
|
||
|
||
// Show modal since user clicked the download button (different from background rehydration)
|
||
if (process.modalElement) {
|
||
process.modalElement.style.display = 'flex';
|
||
}
|
||
console.log(`✅ Rehydrated download modal for ListenBrainz playlist: ${title}`);
|
||
}
|
||
} else {
|
||
console.warn(`⚠️ No Spotify tracks found for ListenBrainz download modal: ${title}`);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Error setting up download process for ListenBrainz playlist "${title}":`, error.message);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// Open discovery modal with existing state
|
||
openYouTubeDiscoveryModal(identifier);
|
||
|
||
// If still discovering, resume polling
|
||
if (existingState.phase === 'discovering') {
|
||
console.log(`🔄 Resuming discovery polling for: ${title}`);
|
||
startListenBrainzDiscoveryPolling(identifier);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// No existing state - create fresh state and start discovery
|
||
console.log(`🆕 Creating fresh ListenBrainz state for: ${title}`);
|
||
|
||
// Create YouTube-style state entry for this ListenBrainz playlist (like Beatport does)
|
||
const listenbrainzState = {
|
||
phase: 'fresh',
|
||
playlist: {
|
||
name: title,
|
||
tracks: tracks.map(track => ({
|
||
track_name: track.track_name,
|
||
artist_name: track.artist_name,
|
||
album_name: track.album_name,
|
||
duration_ms: track.duration_ms || 0,
|
||
mbid: track.mbid,
|
||
release_mbid: track.release_mbid,
|
||
album_cover_url: track.album_cover_url
|
||
})),
|
||
description: `${tracks.length} tracks from ${title}`,
|
||
source: 'listenbrainz'
|
||
},
|
||
is_listenbrainz_playlist: true,
|
||
playlist_mbid: identifier, // Link to ListenBrainz playlist
|
||
// Initialize discovery state properties (both naming conventions for modal compatibility)
|
||
discovery_results: [],
|
||
discoveryResults: [],
|
||
discovery_progress: 0,
|
||
discoveryProgress: 0,
|
||
spotify_matches: 0,
|
||
spotifyMatches: 0,
|
||
spotify_total: tracks.length,
|
||
spotifyTotal: tracks.length
|
||
};
|
||
|
||
// Store in ListenBrainz playlist states
|
||
listenbrainzPlaylistStates[identifier] = listenbrainzState;
|
||
|
||
// Start discovery automatically (like Beatport and Tidal do)
|
||
try {
|
||
console.log(`🔍 Starting ListenBrainz discovery for: ${title}`);
|
||
|
||
// Call the discovery start endpoint with playlist data
|
||
const response = await fetch(`/api/listenbrainz/discovery/start/${identifier}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
playlist: listenbrainzState.playlist
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
// Update state to discovering
|
||
listenbrainzPlaylistStates[identifier].phase = 'discovering';
|
||
|
||
// Start polling for progress
|
||
startListenBrainzDiscoveryPolling(identifier);
|
||
|
||
console.log(`✅ Started ListenBrainz discovery for: ${title}`);
|
||
} else {
|
||
console.error('❌ Error starting ListenBrainz discovery:', result.error);
|
||
showToast(`Error starting discovery: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Error starting ListenBrainz discovery:', error);
|
||
showToast(`Error starting discovery: ${error.message}`, 'error');
|
||
}
|
||
|
||
// Open the existing YouTube discovery modal infrastructure
|
||
openYouTubeDiscoveryModal(identifier);
|
||
|
||
console.log(`✅ ListenBrainz discovery modal opened for ${title} with ${tracks.length} tracks`);
|
||
|
||
} catch (error) {
|
||
console.error('Error opening discovery modal for ListenBrainz playlist:', error);
|
||
showToast('Failed to open discovery modal', 'error');
|
||
}
|
||
}
|
||
|
||
async function openListenBrainzPlaylist(playlistMbid, playlistName) {
|
||
try {
|
||
showLoadingOverlay(`Loading ${playlistName}...`);
|
||
|
||
const response = await fetch(`/api/discover/listenbrainz/playlist/${playlistMbid}`);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch playlist');
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (!data.success || !data.playlist) {
|
||
showToast('Failed to load playlist', 'error');
|
||
hideLoadingOverlay();
|
||
return;
|
||
}
|
||
|
||
const playlist = data.playlist;
|
||
const tracks = playlist.tracks || [];
|
||
|
||
if (tracks.length === 0) {
|
||
showToast('This playlist is empty', 'info');
|
||
hideLoadingOverlay();
|
||
return;
|
||
}
|
||
|
||
// Convert to Spotify-like format for compatibility with download modal
|
||
const spotifyTracks = tracks.map(track => ({
|
||
id: track.recording_mbid || `listenbrainz_${track.title}_${track.creator}`.replace(/[^a-z0-9]/gi, '_'), // Generate ID if missing
|
||
name: track.title || 'Unknown',
|
||
artists: [{ name: cleanArtistName(track.creator || 'Unknown') }], // Proper Spotify format
|
||
album: {
|
||
name: track.album || 'Unknown Album',
|
||
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
|
||
},
|
||
duration_ms: track.duration_ms || 0,
|
||
listenbrainz_metadata: track.additional_metadata
|
||
}));
|
||
|
||
const virtualPlaylistId = `listenbrainz_${playlistMbid}`;
|
||
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
||
hideLoadingOverlay();
|
||
|
||
} catch (error) {
|
||
console.error(`Error opening ListenBrainz playlist:`, error);
|
||
showToast(`Failed to load playlist`, 'error');
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
async function refreshListenBrainzPlaylists() {
|
||
const button = document.getElementById('listenbrainz-refresh-btn');
|
||
if (!button) return;
|
||
|
||
try {
|
||
// Show loading state on button
|
||
const originalContent = button.innerHTML;
|
||
button.disabled = true;
|
||
button.innerHTML = '<span class="button-icon">⏳</span><span class="button-text">Refreshing...</span>';
|
||
|
||
console.log('🔄 Refreshing ListenBrainz playlists...');
|
||
showToast('Refreshing ListenBrainz playlists...', 'info');
|
||
|
||
const response = await fetch('/api/discover/listenbrainz/refresh', {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to refresh: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
const summary = data.summary || {};
|
||
let message = 'ListenBrainz playlists refreshed!';
|
||
|
||
// Build summary message
|
||
const updates = [];
|
||
for (const [type, stats] of Object.entries(summary)) {
|
||
const total = (stats.new || 0) + (stats.updated || 0);
|
||
if (total > 0) {
|
||
updates.push(`${total} ${type}`);
|
||
}
|
||
}
|
||
|
||
if (updates.length > 0) {
|
||
message += ` Updated: ${updates.join(', ')}`;
|
||
} else {
|
||
message = 'All playlists are up to date';
|
||
}
|
||
|
||
console.log('✅ Refresh complete:', data.summary);
|
||
showToast(message, 'success');
|
||
|
||
// Reload the tabs to show updated data
|
||
await initializeListenBrainzTabs();
|
||
|
||
} else {
|
||
throw new Error(data.error || 'Unknown error');
|
||
}
|
||
|
||
// Restore button
|
||
button.disabled = false;
|
||
button.innerHTML = originalContent;
|
||
|
||
} catch (error) {
|
||
console.error('Error refreshing ListenBrainz playlists:', error);
|
||
showToast(`Failed to refresh: ${error.message}`, 'error');
|
||
|
||
// Restore button
|
||
button.disabled = false;
|
||
button.innerHTML = '<span class="button-icon">🔄</span><span class="button-text">Refresh</span>';
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// SEASONAL DISCOVERY
|
||
// ===============================
|
||
|
||
async function loadSeasonalContent() {
|
||
try {
|
||
const response = await fetch('/api/discover/seasonal/current');
|
||
if (!response.ok) {
|
||
console.error('Failed to fetch seasonal content');
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// If no active season, hide seasonal sections
|
||
if (!data.success || !data.season) {
|
||
hideSeasonalSections();
|
||
return;
|
||
}
|
||
|
||
currentSeasonKey = data.season;
|
||
|
||
// Load seasonal albums
|
||
await loadSeasonalAlbums(data);
|
||
|
||
// Load seasonal playlist if available
|
||
if (data.playlist_available) {
|
||
await loadSeasonalPlaylist(data);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error loading seasonal content:', error);
|
||
hideSeasonalSections();
|
||
}
|
||
}
|
||
|
||
function _renderSeasonalAlbumCard(album, index) {
|
||
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
|
||
return `
|
||
<div class="discover-card" onclick="openDownloadModalForSeasonalAlbum(${index})" style="cursor: pointer;">
|
||
<div class="discover-card-image">
|
||
<img src="${coverUrl}" alt="${album.album_name}" loading="lazy">
|
||
</div>
|
||
<div class="discover-card-info">
|
||
<h4 class="discover-card-title">${album.album_name}</h4>
|
||
<p class="discover-card-subtitle">${album.artist_name}</p>
|
||
${album.release_date ? `<p class="discover-card-meta">${album.release_date}</p>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Seasonal Albums uses no-fetch `data:` mode — the parent
|
||
// `loadSeasonalContent` already fetched the season payload and passes
|
||
// the album list directly. Controller is recreated per call so the
|
||
// per-season `data` snapshot is current.
|
||
async function loadSeasonalAlbums(seasonData) {
|
||
const albums = (seasonData && seasonData.albums) || [];
|
||
const ctrl = createDiscoverSectionController({
|
||
id: 'seasonal-albums',
|
||
sectionEl: '#seasonal-albums-section',
|
||
contentEl: '#seasonal-albums-carousel',
|
||
data: { success: true, albums },
|
||
extractItems: (data) => data.albums,
|
||
beforeLoad: () => {
|
||
const section = document.getElementById('seasonal-albums-section');
|
||
if (section) section.style.display = 'block';
|
||
const titleEl = document.getElementById('seasonal-albums-title');
|
||
const subtitleEl = document.getElementById('seasonal-albums-subtitle');
|
||
if (titleEl && seasonData) titleEl.textContent = `${seasonData.icon} ${seasonData.name}`;
|
||
if (subtitleEl && seasonData) subtitleEl.textContent = seasonData.description;
|
||
},
|
||
renderItems: (items) => {
|
||
discoverSeasonalAlbums = items;
|
||
return items.map((album, i) => _renderSeasonalAlbumCard(album, i)).join('');
|
||
},
|
||
emptyMessage: 'No seasonal albums found',
|
||
errorMessage: 'Failed to load seasonal albums',
|
||
verboseErrors: true,
|
||
showErrorToast: true,
|
||
});
|
||
return ctrl.load();
|
||
}
|
||
|
||
let _seasonalPlaylistCtrl = null;
|
||
let _seasonalPlaylistCtrlKey = null;
|
||
|
||
async function loadSeasonalPlaylist(seasonData) {
|
||
const playlistContainer = document.getElementById('seasonal-playlist');
|
||
if (!playlistContainer) return;
|
||
|
||
// Show seasonal playlist section
|
||
const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section');
|
||
if (seasonalPlaylistSection) {
|
||
seasonalPlaylistSection.style.display = 'block';
|
||
}
|
||
|
||
// Update header
|
||
const playlistTitle = document.getElementById('seasonal-playlist-title');
|
||
const playlistSubtitle = document.getElementById('seasonal-playlist-subtitle');
|
||
|
||
if (playlistTitle) {
|
||
playlistTitle.textContent = `${seasonData.icon} ${seasonData.name} Mix`;
|
||
}
|
||
if (playlistSubtitle) {
|
||
playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`;
|
||
}
|
||
|
||
// Re-create the controller when the season key changes so the
|
||
// fetchUrl always points at the active season's endpoint.
|
||
if (!_seasonalPlaylistCtrl || _seasonalPlaylistCtrlKey !== currentSeasonKey) {
|
||
_seasonalPlaylistCtrl = createDiscoverSectionController({
|
||
id: 'seasonal-playlist',
|
||
contentEl: '#seasonal-playlist',
|
||
fetchUrl: `/api/discover/seasonal/${currentSeasonKey}/playlist`,
|
||
extractItems: (data) => data.tracks || [],
|
||
renderItems: (items) => {
|
||
discoverSeasonalTracks = items;
|
||
const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join('');
|
||
return `<div class="discover-playlist-tracks-compact">${rows}</div>`;
|
||
},
|
||
loadingMessage: 'Loading playlist...',
|
||
emptyMessage: 'No tracks available yet',
|
||
errorMessage: 'Failed to load playlist',
|
||
verboseErrors: true,
|
||
showErrorToast: true,
|
||
});
|
||
_seasonalPlaylistCtrlKey = currentSeasonKey;
|
||
}
|
||
return _seasonalPlaylistCtrl.load();
|
||
}
|
||
|
||
function hideSeasonalSections() {
|
||
const seasonalAlbumsSection = document.getElementById('seasonal-albums-section');
|
||
const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section');
|
||
|
||
if (seasonalAlbumsSection) {
|
||
seasonalAlbumsSection.style.display = 'none';
|
||
}
|
||
if (seasonalPlaylistSection) {
|
||
seasonalPlaylistSection.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function _buildDiscoverArtistContext(source, artistName, sourceData = {}, albumData = {}) {
|
||
const normalizedSource = (source || '').toString().toLowerCase();
|
||
const albumArtist = Array.isArray(albumData.artists) ? albumData.artists[0] : null;
|
||
const activeSource = (source || sourceData.active_source || sourceData.source || '').toString().toLowerCase();
|
||
const context = {
|
||
...sourceData,
|
||
id: sourceData.active_source_id || sourceData.artist_id || albumArtist?.id || '',
|
||
name: artistName || sourceData.artist_name || sourceData.name || albumArtist?.name || '',
|
||
source: normalizedSource || activeSource || '',
|
||
spotify_artist_id: sourceData.spotify_artist_id || sourceData.artist_spotify_id || ((normalizedSource || activeSource) === 'spotify' ? albumArtist?.id : '') || '',
|
||
itunes_artist_id: sourceData.itunes_artist_id || sourceData.artist_itunes_id || ((normalizedSource || activeSource) === 'itunes' ? albumArtist?.id : '') || '',
|
||
deezer_artist_id: sourceData.deezer_artist_id || sourceData.artist_deezer_id || sourceData.deezer_id || ((normalizedSource || activeSource) === 'deezer' ? albumArtist?.id : '') || '',
|
||
discogs_artist_id: sourceData.discogs_artist_id || sourceData.artist_discogs_id || sourceData.discogs_id || '',
|
||
amazon_artist_id: sourceData.amazon_artist_id || sourceData.artist_amazon_id || sourceData.amazon_id || '',
|
||
soul_id: sourceData.soul_id || sourceData.hydrabase_artist_id || '',
|
||
};
|
||
|
||
const sourceIdBySource = {
|
||
spotify: context.spotify_artist_id,
|
||
itunes: context.itunes_artist_id,
|
||
deezer: context.deezer_artist_id,
|
||
discogs: context.discogs_artist_id,
|
||
amazon: context.amazon_artist_id,
|
||
hydrabase: context.soul_id,
|
||
};
|
||
context.id = sourceIdBySource[normalizedSource || activeSource] || context.id;
|
||
return context;
|
||
}
|
||
|
||
async function openDownloadModalForSeasonalAlbum(albumIndex) {
|
||
const album = discoverSeasonalAlbums[albumIndex];
|
||
if (!album) {
|
||
showToast('Album data not found', 'error');
|
||
return;
|
||
}
|
||
|
||
console.log(`📥 Opening Download Missing Tracks modal for seasonal album: ${album.album_name}`);
|
||
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
|
||
|
||
try {
|
||
// Determine source and album ID - use source-agnostic endpoint (matches Recent Releases)
|
||
const source = album.source || (album.spotify_album_id && !album.spotify_album_id.match(/^\d+$/) ? 'spotify' : 'itunes');
|
||
const albumId = album.spotify_album_id;
|
||
|
||
if (!albumId) {
|
||
throw new Error('No album ID available');
|
||
}
|
||
|
||
// Fetch album tracks from appropriate source (pass name/artist for Hydrabase support)
|
||
const _dap1 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
|
||
const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap1}`);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch album tracks');
|
||
}
|
||
|
||
const albumData = await response.json();
|
||
if (!albumData.tracks || albumData.tracks.length === 0) {
|
||
throw new Error('No tracks found in album');
|
||
}
|
||
|
||
// Convert to expected format with full album context (matches Recent Releases)
|
||
const spotifyTracks = 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: 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
|
||
};
|
||
});
|
||
|
||
// Create virtual playlist ID
|
||
const virtualPlaylistId = `seasonal_album_${albumId}`;
|
||
|
||
// Pass proper artist/album context for album download (1 worker + source reuse)
|
||
const artistContext = _buildDiscoverArtistContext(source, album.artist_name, album, albumData);
|
||
|
||
const albumContext = {
|
||
id: albumData.id,
|
||
name: albumData.name,
|
||
source: source,
|
||
album_type: albumData.album_type || 'album',
|
||
total_tracks: albumData.total_tracks || 0,
|
||
release_date: albumData.release_date || '',
|
||
images: albumData.images || []
|
||
};
|
||
|
||
// Open download modal with album context (same as Recent Releases)
|
||
await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext);
|
||
|
||
hideLoadingOverlay();
|
||
|
||
} catch (error) {
|
||
console.error(`Error loading seasonal album: ${error.message}`);
|
||
hideLoadingOverlay();
|
||
showToast(`Failed to load album tracks: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function openDownloadModalForSeasonalPlaylist() {
|
||
if (!discoverSeasonalTracks || discoverSeasonalTracks.length === 0) {
|
||
alert('No seasonal tracks available');
|
||
return;
|
||
}
|
||
|
||
// Convert to track format expected by modal
|
||
const tracks = discoverSeasonalTracks.map(track => ({
|
||
id: track.spotify_track_id,
|
||
name: track.track_name,
|
||
artists: [{ name: track.artist_name }],
|
||
album: { name: track.album_name }
|
||
}));
|
||
|
||
openDownloadMissingModal(tracks, `${currentSeasonKey} Seasonal Mix`);
|
||
}
|
||
|
||
async function syncSeasonalPlaylist() {
|
||
if (!currentSeasonKey) {
|
||
alert('No active season');
|
||
return;
|
||
}
|
||
|
||
// Use the same sync logic as other discover playlists
|
||
// Create a virtual playlist ID for tracking
|
||
const virtualPlaylistId = `discover_seasonal_${currentSeasonKey}`;
|
||
|
||
// Build playlist data from seasonal tracks
|
||
const playlistData = {
|
||
id: virtualPlaylistId,
|
||
name: `${currentSeasonKey.charAt(0).toUpperCase() + currentSeasonKey.slice(1)} Mix`,
|
||
tracks: discoverSeasonalTracks.map(track => ({
|
||
id: track.spotify_track_id,
|
||
name: track.track_name,
|
||
artists: [{ name: track.artist_name }],
|
||
album: { name: track.album_name },
|
||
duration_ms: track.duration_ms
|
||
}))
|
||
};
|
||
|
||
// Trigger sync (reuse existing sync infrastructure)
|
||
await syncPlaylistToLibrary(playlistData);
|
||
}
|
||
|
||
// ===============================
|
||
// PERSONALIZED PLAYLISTS
|
||
// ===============================
|
||
|
||
async function loadPersonalizedPopularPicks() {
|
||
try {
|
||
const container = document.getElementById('personalized-popular-picks');
|
||
if (!container) return;
|
||
|
||
const response = await fetch('/api/discover/personalized/popular-picks');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||
container.closest('.discover-section').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
personalizedPopularPicks = data.tracks;
|
||
renderCompactPlaylist(container, data.tracks);
|
||
container.closest('.discover-section').style.display = 'block';
|
||
|
||
} catch (error) {
|
||
console.error('Error loading popular picks:', error);
|
||
}
|
||
}
|
||
|
||
async function loadPersonalizedHiddenGems() {
|
||
try {
|
||
const container = document.getElementById('personalized-hidden-gems');
|
||
if (!container) return;
|
||
|
||
const response = await fetch('/api/discover/personalized/hidden-gems');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||
container.closest('.discover-section').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
personalizedHiddenGems = data.tracks;
|
||
renderCompactPlaylist(container, data.tracks);
|
||
container.closest('.discover-section').style.display = 'block';
|
||
|
||
} catch (error) {
|
||
console.error('Error loading hidden gems:', error);
|
||
}
|
||
}
|
||
|
||
async function loadPersonalizedDailyMixes() {
|
||
try {
|
||
const container = document.getElementById('daily-mixes-grid');
|
||
if (!container) return;
|
||
|
||
const response = await fetch('/api/discover/personalized/daily-mixes');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
if (!data.success || !data.mixes || data.mixes.length === 0) {
|
||
container.closest('.discover-section').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
personalizedDailyMixes = data.mixes;
|
||
|
||
// Render Daily Mix cards
|
||
let html = '';
|
||
data.mixes.forEach((mix, index) => {
|
||
const coverUrl = mix.tracks && mix.tracks.length > 0 ?
|
||
(mix.tracks[0].album_cover_url || '/static/placeholder-album.png') :
|
||
'/static/placeholder-album.png';
|
||
|
||
html += `
|
||
<div class="discover-playlist-card" onclick="openDailyMix(${index})">
|
||
<div class="discover-playlist-cover">
|
||
<img src="${coverUrl}" alt="${mix.name}" loading="lazy">
|
||
<div class="playlist-play-overlay">▶</div>
|
||
</div>
|
||
<div class="discover-playlist-info">
|
||
<h4 class="discover-playlist-name">${mix.name}</h4>
|
||
<p class="discover-playlist-description">${mix.description}</p>
|
||
<p class="discover-playlist-count">${mix.track_count} tracks</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
container.closest('.discover-section').style.display = 'block';
|
||
|
||
} catch (error) {
|
||
console.error('Error loading daily mixes:', error);
|
||
}
|
||
}
|
||
|
||
function renderCompactPlaylist(container, tracks) {
|
||
let html = '<div class="discover-playlist-tracks-compact">';
|
||
|
||
tracks.forEach((track, index) => {
|
||
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
|
||
const durationMin = Math.floor(track.duration_ms / 60000);
|
||
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
|
||
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||
const artistEsc = (track.artist_name || '').replace(/'/g, "\\'").replace(/"/g, '"');
|
||
|
||
html += `
|
||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||
<div class="track-compact-number">${index + 1}</div>
|
||
<div class="track-compact-image">
|
||
<img src="${coverUrl}" alt="${track.album_name}" loading="lazy">
|
||
</div>
|
||
<div class="track-compact-info">
|
||
<div class="track-compact-name">${track.track_name}</div>
|
||
<div class="track-compact-artist">${track.artist_name}</div>
|
||
</div>
|
||
<div class="track-compact-album">${track.album_name}</div>
|
||
<div class="track-compact-duration">${duration}</div>
|
||
<button class="track-compact-block" onclick="event.stopPropagation(); blockDiscoveryArtist('${artistEsc}')" title="Block ${artistEsc} from discovery">✕</button>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
async function blockDiscoveryArtist(artistName) {
|
||
if (!confirm(`Block "${artistName}" from all discovery playlists?`)) return;
|
||
try {
|
||
const res = await fetch('/api/discover/artist-blacklist', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ artist_name: artistName })
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
showToast(`Blocked ${artistName} from discovery`, 'success');
|
||
// Refresh discovery sections to remove the artist. Daily Mixes
|
||
// is intentionally paused (see loadDiscoverPage), so don't
|
||
// refresh it — the section isn't on the page anyway.
|
||
loadPersonalizedHiddenGems();
|
||
loadDiscoveryShuffle();
|
||
} else {
|
||
showToast(data.error || 'Failed to block artist', 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast('Error blocking artist', 'error');
|
||
}
|
||
}
|
||
|
||
async function openDiscoveryBlacklistModal() {
|
||
if (document.getElementById('discovery-blacklist-modal-overlay')) return;
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'discovery-blacklist-modal-overlay';
|
||
overlay.className = 'modal-overlay';
|
||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||
|
||
overlay.innerHTML = `
|
||
<div class="discover-blacklist-modal">
|
||
<div class="discover-blacklist-modal-header">
|
||
<h2>Blocked Artists</h2>
|
||
<p>These artists won't appear in any discovery playlist across all sources</p>
|
||
<button class="watch-all-close" onclick="document.getElementById('discovery-blacklist-modal-overlay').remove()">×</button>
|
||
</div>
|
||
<div class="discover-blacklist-modal-search">
|
||
<input type="text" id="dbl-search-input" placeholder="Search for an artist to block..." autocomplete="off">
|
||
<div id="dbl-search-results" class="dbl-search-results" style="display:none"></div>
|
||
</div>
|
||
<div class="discover-blacklist-modal-list" id="dbl-list">
|
||
<div class="discover-blacklist-empty">Loading...</div>
|
||
</div>
|
||
<div class="discover-blacklist-modal-footer">
|
||
<button class="watch-all-btn watch-all-btn-cancel" onclick="document.getElementById('discovery-blacklist-modal-overlay').remove()">Close</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
|
||
// Wire up search
|
||
let searchTimer = null;
|
||
const input = document.getElementById('dbl-search-input');
|
||
input.addEventListener('input', () => {
|
||
clearTimeout(searchTimer);
|
||
const q = input.value.trim();
|
||
if (q.length < 2) { document.getElementById('dbl-search-results').style.display = 'none'; return; }
|
||
searchTimer = setTimeout(() => _dblSearch(q), 300);
|
||
});
|
||
|
||
_dblLoadList();
|
||
}
|
||
|
||
async function _dblSearch(query) {
|
||
const resultsEl = document.getElementById('dbl-search-results');
|
||
if (!resultsEl) return;
|
||
try {
|
||
// Use existing enhanced search to find artists
|
||
const res = await fetch('/api/enhanced-search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ query, limit: 8 })
|
||
});
|
||
const data = await res.json();
|
||
const artists = data.spotify_artists || data.artists || [];
|
||
if (artists.length === 0) {
|
||
resultsEl.innerHTML = '<div class="dbl-search-empty">No artists found</div>';
|
||
resultsEl.style.display = 'block';
|
||
return;
|
||
}
|
||
resultsEl.innerHTML = artists.map(a => {
|
||
const name = _escToast(a.name || '');
|
||
const img = a.image_url ? `<img src="${a.image_url}" class="dbl-search-img">` : '<div class="dbl-search-img-placeholder">🎤</div>';
|
||
return `<div class="dbl-search-item" onclick="_dblBlockFromSearch('${name.replace(/'/g, "\\'")}')">
|
||
${img}
|
||
<span class="dbl-search-name">${name}</span>
|
||
<span class="dbl-search-action">Block</span>
|
||
</div>`;
|
||
}).join('');
|
||
resultsEl.style.display = 'block';
|
||
} catch (e) {
|
||
resultsEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function _dblBlockFromSearch(artistName) {
|
||
try {
|
||
const res = await fetch('/api/discover/artist-blacklist', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ artist_name: artistName })
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
showToast(`Blocked ${artistName} from discovery`, 'success');
|
||
document.getElementById('dbl-search-results').style.display = 'none';
|
||
const input = document.getElementById('dbl-search-input');
|
||
if (input) input.value = '';
|
||
_dblLoadList();
|
||
}
|
||
} catch (e) {
|
||
showToast('Error blocking artist', 'error');
|
||
}
|
||
}
|
||
|
||
async function _dblLoadList() {
|
||
const container = document.getElementById('dbl-list');
|
||
if (!container) return;
|
||
try {
|
||
const res = await fetch('/api/discover/artist-blacklist');
|
||
const data = await res.json();
|
||
if (!data.success || !data.entries || data.entries.length === 0) {
|
||
container.innerHTML = '<div class="discover-blacklist-empty">No blocked artists yet — search above to block one</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = data.entries.map(e => `
|
||
<div class="discover-blacklist-item">
|
||
<span class="discover-blacklist-name">${_escToast(e.artist_name)}</span>
|
||
<span class="discover-blacklist-date">${e.created_at ? new Date(e.created_at).toLocaleDateString() : ''}</span>
|
||
<button class="discover-blacklist-remove" onclick="unblockDiscoveryArtist(${e.id}, '${_escAttr(e.artist_name)}')" title="Unblock">✕</button>
|
||
</div>
|
||
`).join('');
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="discover-blacklist-empty">Failed to load</div>';
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
function _pickArtistDetailSource(artist) {
|
||
if (!artist) return { id: '', source: '' };
|
||
const sourceFields = {
|
||
spotify: 'spotify_artist_id',
|
||
deezer: 'deezer_artist_id',
|
||
itunes: 'itunes_artist_id',
|
||
discogs: 'discogs_artist_id',
|
||
amazon: 'amazon_artist_id',
|
||
musicbrainz: 'musicbrainz_artist_id',
|
||
hydrabase: 'soul_id',
|
||
};
|
||
const active = String(artist.active_source || artist.source || '').toLowerCase();
|
||
const activeField = sourceFields[active];
|
||
if (activeField && artist[activeField]) {
|
||
return { id: String(artist[activeField]), source: active };
|
||
}
|
||
for (const [source, field] of Object.entries(sourceFields)) {
|
||
if (artist[field]) return { id: String(artist[field]), source };
|
||
}
|
||
if (artist.active_source_id && activeField) {
|
||
return { id: String(artist.active_source_id), source: active };
|
||
}
|
||
return { id: '', source: '' };
|
||
}
|
||
|
||
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 `
|
||
<div class="ya-loading">
|
||
<div class="watch-all-loading-spinner"></div>
|
||
<span>Fetching and matching artists from your services...</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 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 =>
|
||
`<div class="ya-badge" title="${b.title}">${b.logo ? `<img src="${b.logo}" onerror="this.parentNode.textContent='${b.fb}'">` : `<span>${b.fb}</span>`}</div>`
|
||
).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 =>
|
||
`<span class="ya-origin-dot" style="background:${sourceColors[s] || '#666'}" title="From ${s}"></span>`
|
||
).join('');
|
||
|
||
const watchlistClass = artist.on_watchlist ? 'active' : '';
|
||
const detailSource = _pickArtistDetailSource(artist);
|
||
const hasId = detailSource.id && detailSource.id !== '';
|
||
|
||
const detailHref = hasId ? buildArtistDetailPath(detailSource.id, detailSource.source) : '';
|
||
|
||
// 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 `
|
||
<div class="ya-card" ${infoAction ? `onclick="${infoAction}"` : ''}>
|
||
<div class="ya-card-img">
|
||
${img ? `<img src="${img}" alt="" loading="lazy" ${deezerFb}>` : ''}
|
||
<div class="ya-card-placeholder" ${img ? 'style="display:none"' : ''}>♫</div>
|
||
</div>
|
||
<div class="ya-card-gradient"></div>
|
||
<div class="ya-card-badges">${badgeHTML}</div>
|
||
<button class="ya-watchlist-btn ${watchlistClass}" title="${artist.on_watchlist ? 'On watchlist' : 'Add to watchlist'}"
|
||
onclick="event.stopPropagation(); toggleYourArtistWatchlist(${artist.id}, '${escapeForInlineJs(artist.artist_name)}', '${escapeForInlineJs(artist.active_source_id || '')}', '${escapeForInlineJs(artist.active_source || '')}', this)">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="${artist.on_watchlist ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2">
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
</button>
|
||
<div class="ya-card-info">
|
||
<div class="ya-card-info-row">
|
||
<div class="ya-origin-dots">${originDots}</div>
|
||
</div>
|
||
${hasId
|
||
? `<a class="ya-card-name" href="${detailHref}" onclick="event.stopPropagation(); document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove();" style="display:block;text-decoration:none;color:inherit;">${_esc(artist.artist_name)}</a>`
|
||
: `<div class="ya-card-name">${_esc(artist.artist_name)}</div>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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) => `<div class="ya-info-badge" title="${title}">${logo ? `<img src="${logo}" onerror="this.parentNode.textContent='${fb}'">` : `<span>${fb}</span>`}</div>`;
|
||
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 = `
|
||
<div class="ya-info-modal">
|
||
<button class="watch-all-close" onclick="document.getElementById('ya-info-modal-overlay').remove()">×</button>
|
||
<div class="ya-info-hero">
|
||
<div class="ya-info-hero-bg" ${imageUrl ? `style="background-image:url('${escapeHtml(imageUrl)}')"` : ''}></div>
|
||
<div class="ya-info-hero-content">
|
||
<div class="ya-info-hero-img">
|
||
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="">` : '<div class="ya-info-img-fallback">♫</div>'}
|
||
</div>
|
||
<div class="ya-info-hero-text">
|
||
<h2 class="ya-info-name">${escapeHtml(artistName)}</h2>
|
||
<div class="ya-info-badges">${matchBadges.join('')}</div>
|
||
${originText ? `<div class="ya-info-origin">Followed on ${escapeHtml(originText)}</div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ya-info-body" id="ya-info-body">
|
||
<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Loading artist info...</div></div>
|
||
</div>
|
||
<div class="ya-info-footer" id="ya-info-footer"></div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
|
||
// Fetch enrichment data (with timeout)
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||
const lookupId = artistId || encodeURIComponent(artistName);
|
||
const resp = await fetch(`/api/discover/your-artists/info/${lookupId}?name=${encodeURIComponent(artistName)}`, { signal: controller.signal });
|
||
clearTimeout(timeout);
|
||
const artist = resp.ok ? await resp.json() : {};
|
||
const bodyEl = document.getElementById('ya-info-body');
|
||
const footerEl = document.getElementById('ya-info-footer');
|
||
|
||
const genres = artist.genres || [];
|
||
const bio = artist.summary || '';
|
||
const listeners = artist.lastfm_listeners || artist.followers || 0;
|
||
const playcount = artist.lastfm_playcount || 0;
|
||
const popularity = artist.popularity || 0;
|
||
|
||
let bodyHTML = '';
|
||
|
||
// Stats
|
||
if (listeners || playcount || popularity) {
|
||
bodyHTML += `<div class="ya-info-stats">
|
||
${listeners ? `<div class="ya-info-stat"><span class="ya-info-stat-value">${Number(listeners).toLocaleString()}</span><span class="ya-info-stat-label">listeners</span></div>` : ''}
|
||
${playcount ? `<div class="ya-info-stat"><span class="ya-info-stat-value">${Number(playcount).toLocaleString()}</span><span class="ya-info-stat-label">plays</span></div>` : ''}
|
||
${popularity ? `<div class="ya-info-stat"><span class="ya-info-stat-value">${popularity}</span><span class="ya-info-stat-label">popularity</span></div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
// Genres
|
||
if (genres.length > 0) {
|
||
bodyHTML += `<div class="ya-info-section">
|
||
<div class="ya-info-genres">${genres.map(g => `<span class="ya-info-genre">${escapeHtml(g)}</span>`).join('')}</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Bio
|
||
if (bio) {
|
||
const cleanBio = bio.replace(/<a[^>]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim();
|
||
if (cleanBio) {
|
||
bodyHTML += `<div class="ya-info-section">
|
||
<div class="ya-info-section-title">About</div>
|
||
<div class="ya-info-bio">${escapeHtml(cleanBio.length > 600 ? cleanBio.substring(0, 600) + '...' : cleanBio)}</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// Related artists from map connections
|
||
const related = pool._related || [];
|
||
if (related.length > 0) {
|
||
const relLabel = pool.on_watchlist ? 'Similar Artists' : 'Connected To';
|
||
bodyHTML += `<div class="ya-info-section">
|
||
<div class="ya-info-section-title">${relLabel}</div>
|
||
<div class="ya-info-related">
|
||
${related.slice(0, 12).map(r => {
|
||
const rImg = r.image_url || '';
|
||
const rType = r.type === 'watchlist';
|
||
return `<div class="ya-info-related-item" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); setTimeout(() => openYourArtistInfoModal_direct(${JSON.stringify({
|
||
id: r.id, name: r.name, image_url: rImg,
|
||
spotify_id: r.spotify_id || '', itunes_id: r.itunes_id || '',
|
||
deezer_id: r.deezer_id || '', discogs_id: r.discogs_id || '',
|
||
type: r.type
|
||
}).replace(/"/g, '"')}), 100)">
|
||
<div class="ya-info-related-img">
|
||
${rImg ? `<img src="${escapeHtml(rImg)}" alt="">` : '<span>♫</span>'}
|
||
</div>
|
||
<div class="ya-info-related-text">
|
||
<div class="ya-info-related-name">${escapeHtml(r.name)}</div>
|
||
${rType ? '<div class="ya-info-related-badge">★ Watchlist</div>' : ''}
|
||
</div>
|
||
</div>`;
|
||
}).join('')}
|
||
${related.length > 12 ? `<div class="ya-info-related-more">+${related.length - 12} more</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
if (!bodyHTML) bodyHTML = '<div class="ya-info-empty">No additional info available</div>';
|
||
if (bodyEl) bodyEl.innerHTML = bodyHTML;
|
||
|
||
// Footer
|
||
if (footerEl) {
|
||
const watchBtn = pool.on_watchlist
|
||
? `<button class="btn btn--sm btn--secondary ya-header-btn" onclick="toggleYourArtistWatchlist(${pool.id}, '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(pool.active_source || '')}', this); this.textContent='Done'; this.disabled=true">Remove from Watchlist</button>`
|
||
: `<button class="btn btn--sm btn--secondary ya-header-btn" onclick="toggleYourArtistWatchlist(${pool.id}, '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(pool.active_source || '')}', this); this.textContent='Added!'; this.disabled=true">Add to Watchlist</button>`;
|
||
footerEl.innerHTML = `
|
||
${watchBtn}
|
||
<button class="btn btn--sm btn--secondary ya-header-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); openArtistMapExplorerDirect('${escapeForInlineJs(artistName)}')">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
<span>Explore</span>
|
||
</button>
|
||
<a class="btn btn--sm btn--secondary ya-header-btn ya-viewall-btn" href="${buildArtistDetailPath(artistId, pool.active_source || null)}" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove();" style="text-decoration:none;color:inherit;">
|
||
<span>View Discography</span>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
|
||
</a>
|
||
`;
|
||
}
|
||
} catch (err) {
|
||
console.error('[Artist Info] Error loading artist info:', err);
|
||
const bodyEl = document.getElementById('ya-info-body');
|
||
if (bodyEl) bodyEl.innerHTML = `<div class="ya-info-empty">Could not load artist info</div>`;
|
||
}
|
||
}
|
||
|
||
async function toggleYourArtistWatchlist(poolId, artistName, sourceId, source, btnEl) {
|
||
const isWatched = btnEl && btnEl.classList.contains('active');
|
||
try {
|
||
if (isWatched) {
|
||
const resp = await fetch('/api/watchlist/remove', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ artist_id: sourceId })
|
||
});
|
||
if (resp.ok) {
|
||
if (btnEl) {
|
||
btnEl.classList.remove('active');
|
||
const svg = btnEl.querySelector('svg');
|
||
if (svg) svg.setAttribute('fill', 'none');
|
||
}
|
||
showToast(`Removed ${artistName} from watchlist`, 'info');
|
||
// Sync card eye icon
|
||
_syncYaCardWatchlist(poolId, false);
|
||
}
|
||
} else {
|
||
const resp = await fetch('/api/watchlist/add', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ artist_id: sourceId, artist_name: artistName, source: source })
|
||
});
|
||
if (resp.ok) {
|
||
if (btnEl) {
|
||
btnEl.classList.add('active');
|
||
const svg = btnEl.querySelector('svg');
|
||
if (svg) svg.setAttribute('fill', 'currentColor');
|
||
}
|
||
showToast(`Added ${artistName} to watchlist`, 'success');
|
||
_syncYaCardWatchlist(poolId, true);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
showToast('Failed to update watchlist', 'error');
|
||
}
|
||
}
|
||
|
||
function _syncYaCardWatchlist(poolId, watched) {
|
||
// Sync the card's eye icon with watchlist state (covers modal → card sync)
|
||
document.querySelectorAll('.ya-card .ya-watchlist-btn').forEach(btn => {
|
||
const card = btn.closest('.ya-card');
|
||
if (!card) return;
|
||
// Match by onclick containing the poolId
|
||
const onclick = btn.getAttribute('onclick') || '';
|
||
if (onclick.includes(`(${poolId},`)) {
|
||
if (watched) {
|
||
btn.classList.add('active');
|
||
const svg = btn.querySelector('svg');
|
||
if (svg) svg.setAttribute('fill', 'currentColor');
|
||
} else {
|
||
btn.classList.remove('active');
|
||
const svg = btn.querySelector('svg');
|
||
if (svg) svg.setAttribute('fill', 'none');
|
||
}
|
||
}
|
||
});
|
||
// Update pool data
|
||
if (window._yaArtists && window._yaArtists[poolId]) {
|
||
window._yaArtists[poolId].on_watchlist = watched ? 1 : 0;
|
||
}
|
||
}
|
||
|
||
async function refreshYourArtists() {
|
||
const btn = document.getElementById('your-artists-refresh-btn');
|
||
if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; }
|
||
const subtitle = document.getElementById('your-artists-subtitle');
|
||
if (subtitle) subtitle.textContent = 'Refreshing from your services...';
|
||
|
||
try {
|
||
await fetch('/api/discover/your-artists/refresh?clear=true', { method: 'POST' });
|
||
// Poll until done
|
||
let attempts = 0;
|
||
const poll = setInterval(async () => {
|
||
attempts++;
|
||
if (attempts > 60) { clearInterval(poll); return; } // 5 min max
|
||
try {
|
||
const resp = await fetch('/api/discover/your-artists');
|
||
const data = await resp.json();
|
||
if (!data.stale && data.artists && data.artists.length > 0) {
|
||
clearInterval(poll);
|
||
loadYourArtists();
|
||
if (btn) { btn.disabled = false; btn.style.opacity = ''; }
|
||
showToast(`Found ${data.total} artists from your services`, 'success');
|
||
}
|
||
} catch (e) { }
|
||
}, 5000);
|
||
} catch (err) {
|
||
showToast('Failed to start refresh', 'error');
|
||
if (btn) { btn.disabled = false; btn.style.opacity = ''; }
|
||
}
|
||
}
|
||
|
||
async function openYourArtistsSourcesModal() {
|
||
const existing = document.getElementById('ya-sources-modal-overlay');
|
||
if (existing) existing.remove();
|
||
|
||
// Fetch current config + connected services
|
||
let enabled = ['spotify', 'tidal', 'lastfm', 'deezer'];
|
||
let connected = [];
|
||
try {
|
||
const resp = await fetch('/api/discover/your-artists/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: '🎵' },
|
||
{ id: 'tidal', label: 'Tidal', icon: '🌊' },
|
||
{ id: 'lastfm', label: 'Last.fm', icon: '📻' },
|
||
{ id: 'deezer', label: 'Deezer', icon: '🎶' },
|
||
];
|
||
|
||
const state = {};
|
||
sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); });
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'ya-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 `
|
||
<div class="ya-source-row${isConnected ? '' : ' disconnected'}" data-source="${s.id}" onclick="_yaSourceRowClick('${s.id}')">
|
||
<div class="ya-source-row-left">
|
||
<span style="font-size:18px">${s.icon}</span>
|
||
<div>
|
||
<div class="ya-source-name">${s.label}</div>
|
||
<div class="ya-source-status">${isConnected ? 'Connected' : 'Not connected'}</div>
|
||
</div>
|
||
</div>
|
||
<button class="ya-source-toggle${isOn ? ' on' : ''}" id="ya-toggle-${s.id}" onclick="event.stopPropagation();_yaSourceToggle('${s.id}')"></button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
overlay.innerHTML = `
|
||
<div class="ya-sources-modal">
|
||
<h2>Your Artists Sources</h2>
|
||
<p class="ya-sources-desc">Choose which connected services contribute artists to this section.</p>
|
||
<div class="ya-sources-list">${rows}</div>
|
||
<div class="ya-sources-footer">
|
||
<button class="ya-sources-cancel-btn" onclick="document.getElementById('ya-sources-modal-overlay').remove()">Cancel</button>
|
||
<button class="ya-sources-save-btn" onclick="_yaSourcesSave()">Save</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
window._yaSourcesState = state;
|
||
}
|
||
|
||
function _yaSourceRowClick(id) {
|
||
// Don't allow toggling disconnected services
|
||
const row = document.querySelector(`.ya-source-row[data-source="${id}"]`);
|
||
if (row && row.classList.contains('disconnected')) return;
|
||
_yaSourceToggle(id);
|
||
}
|
||
|
||
function _yaSourceToggle(id) {
|
||
// Don't allow toggling disconnected services
|
||
const row = document.querySelector(`.ya-source-row[data-source="${id}"]`);
|
||
if (row && row.classList.contains('disconnected')) return;
|
||
window._yaSourcesState[id] = !window._yaSourcesState[id];
|
||
const btn = document.getElementById(`ya-toggle-${id}`);
|
||
if (btn) btn.classList.toggle('on', window._yaSourcesState[id]);
|
||
}
|
||
|
||
async function _yaSourcesSave() {
|
||
const enabledArr = Object.entries(window._yaSourcesState)
|
||
.filter(([, v]) => v).map(([k]) => k);
|
||
if (enabledArr.length === 0) {
|
||
showToast('Select at least one source', 'error');
|
||
return;
|
||
}
|
||
const enabled = enabledArr.join(',');
|
||
try {
|
||
const resp = await fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ discover: { your_artists_sources: enabled } })
|
||
});
|
||
if (resp.ok) {
|
||
document.getElementById('ya-sources-modal-overlay')?.remove();
|
||
showToast('Sources saved — refresh to apply', 'success');
|
||
// Update subtitle immediately
|
||
const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', lastfm: 'Last.fm', deezer: 'Deezer' };
|
||
const subtitle = document.getElementById('your-artists-subtitle');
|
||
if (subtitle) {
|
||
const names = enabledArr.map(s => sourceNames[s] || s).join(' and ');
|
||
subtitle.textContent = `Artists you follow on ${names}`;
|
||
}
|
||
} else {
|
||
showToast('Failed to save sources', 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast('Failed to save sources', 'error');
|
||
}
|
||
}
|
||
|
||
async function openYourArtistsModal() {
|
||
const existing = document.getElementById('your-artists-modal-overlay');
|
||
if (existing) existing.remove();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'your-artists-modal-overlay';
|
||
overlay.className = 'modal-overlay';
|
||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||
|
||
overlay.innerHTML = `
|
||
<div class="ya-modal">
|
||
<div class="ya-modal-header">
|
||
<div>
|
||
<h2 class="ya-modal-title">Your Artists</h2>
|
||
<p class="ya-modal-subtitle" id="ya-modal-subtitle">Loading...</p>
|
||
</div>
|
||
<button class="watch-all-close" onclick="document.getElementById('your-artists-modal-overlay').remove()">×</button>
|
||
</div>
|
||
<div class="ya-modal-toolbar">
|
||
<input type="text" id="ya-modal-search" class="ya-modal-search" placeholder="Search artists...">
|
||
<div class="ya-modal-filters">
|
||
<button class="ya-filter-btn active" data-source="" onclick="_yaFilterSource('')">All</button>
|
||
<button class="ya-filter-btn" data-source="spotify" onclick="_yaFilterSource('spotify')">Spotify</button>
|
||
<button class="ya-filter-btn" data-source="tidal" onclick="_yaFilterSource('tidal')">Tidal</button>
|
||
<button class="ya-filter-btn" data-source="lastfm" onclick="_yaFilterSource('lastfm')">Last.fm</button>
|
||
<button class="ya-filter-btn" data-source="deezer" onclick="_yaFilterSource('deezer')">Deezer</button>
|
||
</div>
|
||
<select class="ya-modal-sort" id="ya-modal-sort" onchange="_yaLoadModal()">
|
||
<option value="name">A-Z</option>
|
||
<option value="recent">Recently Added</option>
|
||
<option value="source">By Source</option>
|
||
</select>
|
||
</div>
|
||
<div class="ya-modal-body" id="ya-modal-body">
|
||
<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Loading...</div></div>
|
||
</div>
|
||
<div class="ya-modal-footer" id="ya-modal-footer"></div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
|
||
// Search debounce
|
||
let searchTimer = null;
|
||
overlay.querySelector('#ya-modal-search').addEventListener('input', () => {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => _yaLoadModal(), 300);
|
||
});
|
||
|
||
window._yaModalState = { page: 1, source: '', sort: 'name' };
|
||
_yaLoadModal();
|
||
}
|
||
|
||
function _yaFilterSource(source) {
|
||
window._yaModalState.source = source;
|
||
window._yaModalState.page = 1;
|
||
document.querySelectorAll('.ya-filter-btn').forEach(b => b.classList.toggle('active', b.dataset.source === source));
|
||
_yaLoadModal();
|
||
}
|
||
|
||
async function _yaLoadModal() {
|
||
const body = document.getElementById('ya-modal-body');
|
||
const footer = document.getElementById('ya-modal-footer');
|
||
const subtitle = document.getElementById('ya-modal-subtitle');
|
||
if (!body) return;
|
||
|
||
const state = window._yaModalState || { page: 1, source: '', sort: 'name' };
|
||
const search = document.getElementById('ya-modal-search')?.value || '';
|
||
const sort = document.getElementById('ya-modal-sort')?.value || 'name';
|
||
state.sort = sort;
|
||
|
||
const params = new URLSearchParams({ page: state.page, per_page: 60, sort: state.sort });
|
||
if (state.source) params.set('source', state.source);
|
||
if (search) params.set('search', search);
|
||
|
||
try {
|
||
const resp = await fetch(`/api/discover/your-artists/all?${params}`);
|
||
const data = await resp.json();
|
||
|
||
if (subtitle) subtitle.textContent = `${data.total} artists matched`;
|
||
|
||
if (!data.artists || data.artists.length === 0) {
|
||
body.innerHTML = '<div class="failed-mb-empty">No artists found</div>';
|
||
if (footer) footer.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// Store for info modal access
|
||
if (!window._yaArtists) window._yaArtists = {};
|
||
data.artists.forEach(a => { window._yaArtists[a.id] = a; });
|
||
body.innerHTML = `<div class="ya-modal-grid">${data.artists.map(a => _renderYourArtistCard(a)).join('')}</div>`;
|
||
|
||
// Pagination
|
||
const totalPages = Math.ceil(data.total / 60);
|
||
if (footer && totalPages > 1) {
|
||
footer.innerHTML = `
|
||
<div class="failed-mb-pagination">
|
||
<button class="failed-mb-btn-sm" ${state.page <= 1 ? 'disabled' : ''} onclick="window._yaModalState.page--; _yaLoadModal()">Prev</button>
|
||
<span>Page ${state.page} of ${totalPages}</span>
|
||
<button class="failed-mb-btn-sm" ${state.page >= totalPages ? 'disabled' : ''} onclick="window._yaModalState.page++; _yaLoadModal()">Next</button>
|
||
</div>
|
||
`;
|
||
} else if (footer) {
|
||
footer.innerHTML = '';
|
||
}
|
||
} catch (err) {
|
||
body.innerHTML = '<div class="failed-mb-empty">Failed to load</div>';
|
||
}
|
||
}
|
||
|
||
function loadDiscoveryBlacklist() { }
|
||
|
||
// ── Artist Map — Circle-packed staged canvas visualization ──
|
||
const _artMap = {
|
||
placed: [],
|
||
edges: [],
|
||
images: {},
|
||
canvas: null, ctx: null,
|
||
offscreen: null, offCtx: null, // offscreen buffer for fast pan/zoom
|
||
width: 0, height: 0,
|
||
offsetX: 0, offsetY: 0, zoom: 0.15,
|
||
hoveredNode: null, animFrame: null,
|
||
dirty: true, // true = need to rebuild offscreen buffer
|
||
WATCHLIST_R: 320,
|
||
BUFFER: 8,
|
||
// Max offscreen-buffer dimension (px). The buffer renders the whole world,
|
||
// so on big/dense maps (e.g. 2000-node genre map) this was 10240 → a 76 MP
|
||
// canvas that took ~1s to rebuild and ~150ms to blit per frame (3 fps).
|
||
// Capping it far lower makes rebuild + blit cheap (and pushes more nodes
|
||
// under the LOD dot threshold). Only binds on large worlds — small maps
|
||
// stay crisp via the z*2 / 1.0 caps. Tunable.
|
||
MAX_BUFFER_PX: 4096,
|
||
};
|
||
|
||
async function openArtistMap() {
|
||
const container = document.getElementById('artist-map-container');
|
||
if (!container) return;
|
||
|
||
// Hide discover sections, show map
|
||
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.images = {};
|
||
_artMap._nodeById = null;
|
||
|
||
// Loading screen
|
||
_artMap.ctx.fillStyle = '#0a0a14';
|
||
_artMap.ctx.fillRect(0, 0, _artMap.width, _artMap.height);
|
||
_artMap.ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||
_artMap.ctx.font = '14px system-ui';
|
||
_artMap.ctx.textAlign = 'center';
|
||
_artMap.ctx.fillText('Building artist map...', _artMap.width / 2, _artMap.height / 2);
|
||
|
||
try {
|
||
const resp = await fetch('/api/discover/artist-map');
|
||
const data = await resp.json();
|
||
if (!data.success || !data.nodes.length) {
|
||
_artMap.ctx.fillText('No watchlist artists. Add artists to your watchlist first.', _artMap.width / 2, _artMap.height / 2 + 30);
|
||
return;
|
||
}
|
||
|
||
document.getElementById('artist-map-stats').textContent =
|
||
`${data.watchlist_count} watchlist · ${data.similar_count} similar`;
|
||
|
||
_artMap.edges = data.edges;
|
||
const WR = _artMap.WATCHLIST_R;
|
||
const BUF = _artMap.BUFFER;
|
||
|
||
// ── PHASE 1: Place watchlist artists with guaranteed no-overlap ──
|
||
const wNodes = data.nodes.filter(n => n.type === 'watchlist');
|
||
// Minimum center-to-center distance between watchlist nodes
|
||
const minCenterDist = WR * 3.5; // WR*2 for radii + WR*1.5 gap — similar artists fill the gaps via spiral packing
|
||
|
||
// Place watchlist nodes in a spiral — deterministic, guaranteed spacing
|
||
wNodes.forEach((n, i) => {
|
||
n.radius = WR;
|
||
n.opacity = 0;
|
||
if (i === 0) {
|
||
n.x = 0; n.y = 0;
|
||
} else {
|
||
// Golden angle spiral for even distribution
|
||
const angle = i * 2.399963; // golden angle in radians
|
||
const r = minCenterDist * Math.sqrt(i) * 0.7;
|
||
n.x = Math.cos(angle) * r;
|
||
n.y = Math.sin(angle) * r;
|
||
}
|
||
});
|
||
|
||
// Post-process: push apart any watchlist nodes that ended up too close
|
||
for (let pass = 0; pass < 50; pass++) {
|
||
let moved = false;
|
||
for (let i = 0; i < wNodes.length; i++) {
|
||
for (let j = i + 1; j < wNodes.length; j++) {
|
||
const dx = wNodes[j].x - wNodes[i].x;
|
||
const dy = wNodes[j].y - wNodes[i].y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
if (dist < minCenterDist) {
|
||
const push = (minCenterDist - dist) / 2 + 1;
|
||
const nx = (dx / dist) * push;
|
||
const ny = (dy / dist) * push;
|
||
wNodes[i].x -= nx; wNodes[i].y -= ny;
|
||
wNodes[j].x += nx; wNodes[j].y += ny;
|
||
moved = true;
|
||
}
|
||
}
|
||
}
|
||
if (!moved) break;
|
||
}
|
||
|
||
wNodes.forEach(n => { _artMap.placed.push(n); });
|
||
|
||
// ── PHASE 2: Place similar artists around their source watchlist nodes ──
|
||
const sNodes = data.nodes.filter(n => n.type === 'similar');
|
||
sNodes.forEach(n => {
|
||
const occ = n.occurrence || 1;
|
||
const rank = n.rank || 5;
|
||
// Bigger overall: min 25% of WR, max 55%, scaled by relevance
|
||
n.radius = Math.min(WR * 0.55, Math.max(WR * 0.25, WR * 0.2 + occ * WR * 0.06 + (10 - rank) * WR * 0.025));
|
||
});
|
||
sNodes.sort((a, b) => b.radius - a.radius);
|
||
|
||
// Build edge lookup: target_id → source node (O(1) instead of .find())
|
||
const edgeMap = {};
|
||
_artMap.edges.forEach(e => { edgeMap[e.target] = e.source; });
|
||
const nodeById = {};
|
||
_artMap.placed.forEach(n => { nodeById[n.id] = n; });
|
||
|
||
// Spatial grid for fast collision detection
|
||
// Cell size must cover the largest possible bubble diameter + buffer
|
||
const CELL = WR * 2 + BUF * 2;
|
||
const grid = {};
|
||
function _gridKey(x, y) { return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`; }
|
||
function _gridAdd(n) {
|
||
const k = _gridKey(n.x, n.y);
|
||
if (!grid[k]) grid[k] = [];
|
||
grid[k].push(n);
|
||
}
|
||
function _gridCheck(x, y, r) {
|
||
const cx = Math.floor(x / CELL);
|
||
const cy = Math.floor(y / CELL);
|
||
// Search wider radius to catch large watchlist bubbles
|
||
for (let dx = -3; dx <= 3; dx++) {
|
||
for (let dy = -3; dy <= 3; dy++) {
|
||
const cell = grid[`${cx + dx},${cy + dy}`];
|
||
if (!cell) continue;
|
||
for (const p of cell) {
|
||
const ddx = x - p.x, ddy = y - p.y;
|
||
const minD = r + p.radius + BUF;
|
||
if (ddx * ddx + ddy * ddy < minD * minD) return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
// Add watchlist nodes to grid
|
||
_artMap.placed.forEach(n => _gridAdd(n));
|
||
|
||
// Place similar nodes with spatial grid collision
|
||
for (const sn of sNodes) {
|
||
const srcId = edgeMap[sn.id];
|
||
const src = srcId != null ? nodeById[srcId] : null;
|
||
const cx = src ? src.x : 0;
|
||
const cy = src ? src.y : 0;
|
||
const startDist = (src ? src.radius : WR) + sn.radius + BUF;
|
||
|
||
let placed = false;
|
||
for (let dist = startDist; dist < startDist + WR * 3; dist += sn.radius * 0.5) {
|
||
const steps = Math.max(8, Math.floor(dist * 0.1));
|
||
const off = Math.random() * Math.PI * 2;
|
||
for (let a = 0; a < steps; a++) {
|
||
const angle = off + (a / steps) * Math.PI * 2;
|
||
const tx = cx + Math.cos(angle) * dist;
|
||
const ty = cy + Math.sin(angle) * dist;
|
||
if (!_gridCheck(tx, ty, sn.radius)) {
|
||
sn.x = tx; sn.y = ty; sn.opacity = 0;
|
||
_artMap.placed.push(sn);
|
||
nodeById[sn.id] = sn;
|
||
_gridAdd(sn);
|
||
placed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (placed) break;
|
||
}
|
||
}
|
||
|
||
// Auto-zoom to fit all nodes
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
_artMap.placed.forEach(n => {
|
||
minX = Math.min(minX, n.x - n.radius);
|
||
maxX = Math.max(maxX, n.x + n.radius);
|
||
minY = Math.min(minY, n.y - n.radius);
|
||
maxY = Math.max(maxY, n.y + n.radius);
|
||
});
|
||
const mapW = maxX - minX + 200;
|
||
const mapH = maxY - minY + 200;
|
||
_artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1);
|
||
_artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom;
|
||
_artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom;
|
||
|
||
// Setup interaction
|
||
_artMapSetupInteraction(canvas);
|
||
|
||
// ── PHASE 3: Set all visible, build buffer, render ──
|
||
// Show loading overlay while buffer builds
|
||
const loadingEl = document.createElement('div');
|
||
loadingEl.id = 'artist-map-loading';
|
||
loadingEl.innerHTML = `
|
||
<div class="artist-map-loading-content">
|
||
<div class="watch-all-loading-spinner"></div>
|
||
<div class="artist-map-loading-text" id="artmap-loading-text">Placing ${_artMap.placed.length} artists on the map...</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(loadingEl);
|
||
|
||
// Defer heavy work so loading overlay renders first
|
||
setTimeout(async () => {
|
||
_artMap.placed.forEach(n => { n.opacity = 1; });
|
||
|
||
// Paint NOW — the map is fully interactive (pan/zoom/hover/click)
|
||
// with placeholder circles before a single image is fetched. Images
|
||
// then stream in throttled waves and sharpen the map in place. This
|
||
// is the "click-click-click" win: no blocking on N image fetches
|
||
// before the first frame.
|
||
_artMap.dirty = true;
|
||
_artMapRender();
|
||
|
||
const le = document.getElementById('artist-map-loading');
|
||
if (le) le.remove();
|
||
|
||
_artMapStreamImages(_artMap.placed);
|
||
}, 50);
|
||
|
||
} catch (err) {
|
||
console.error('Artist map error:', err);
|
||
}
|
||
}
|
||
|
||
function artMapZoom(factor) {
|
||
const cx = _artMap.width / 2;
|
||
const cy = _artMap.height / 2;
|
||
const targetZoom = Math.max(0.02, Math.min(3, _artMap.zoom * factor));
|
||
const targetOX = cx - (cx - _artMap.offsetX) * (targetZoom / _artMap.zoom);
|
||
const targetOY = cy - (cy - _artMap.offsetY) * (targetZoom / _artMap.zoom);
|
||
_artMapAnimateTo(targetZoom, targetOX, targetOY);
|
||
}
|
||
|
||
function artMapFitToView() {
|
||
if (!_artMap.placed.length) return;
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
_artMap.placed.forEach(n => {
|
||
if ((n.opacity || 0) < 0.01) return;
|
||
minX = Math.min(minX, n.x - n.radius);
|
||
maxX = Math.max(maxX, n.x + n.radius);
|
||
minY = Math.min(minY, n.y - n.radius);
|
||
maxY = Math.max(maxY, n.y + n.radius);
|
||
});
|
||
const mapW = maxX - minX + 100;
|
||
const mapH = maxY - minY + 100;
|
||
const targetZoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1);
|
||
const targetOX = _artMap.width / 2 - ((minX + maxX) / 2) * targetZoom;
|
||
const targetOY = _artMap.height / 2 - ((minY + maxY) / 2) * targetZoom;
|
||
_artMapAnimateTo(targetZoom, targetOX, targetOY);
|
||
}
|
||
|
||
function _artMapAnimateTo(targetZoom, targetOX, targetOY) {
|
||
if (_artMap._animating) cancelAnimationFrame(_artMap._animating);
|
||
const startZoom = _artMap.zoom;
|
||
const startOX = _artMap.offsetX;
|
||
const startOY = _artMap.offsetY;
|
||
const duration = 250;
|
||
const start = performance.now();
|
||
|
||
function step(now) {
|
||
const t = Math.min(1, (now - start) / duration);
|
||
// Ease out cubic
|
||
const e = 1 - Math.pow(1 - t, 3);
|
||
_artMap.zoom = startZoom + (targetZoom - startZoom) * e;
|
||
_artMap.offsetX = startOX + (targetOX - startOX) * e;
|
||
_artMap.offsetY = startOY + (targetOY - startOY) * e;
|
||
_artMapRender(); // blit only, no rebuild
|
||
if (t < 1) {
|
||
_artMap._animating = requestAnimationFrame(step);
|
||
} else {
|
||
_artMap._animating = null;
|
||
_artMap.dirty = true;
|
||
_artMapRender(); // rebuild at final zoom level
|
||
}
|
||
}
|
||
_artMap._animating = requestAnimationFrame(step);
|
||
}
|
||
|
||
function closeArtistMap() {
|
||
const container = document.getElementById('artist-map-container');
|
||
if (container) container.style.display = 'none';
|
||
const sidebar = document.getElementById('artmap-genre-sidebar');
|
||
if (sidebar) sidebar.style.display = 'none';
|
||
if (_artMap.animFrame) cancelAnimationFrame(_artMap.animFrame);
|
||
if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler);
|
||
_artMapHideContextMenu();
|
||
|
||
// Restore discover sections
|
||
document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => {
|
||
el.style.display = el._prevDisplay !== undefined ? el._prevDisplay : '';
|
||
});
|
||
}
|
||
|
||
// No force simulation — layout is pre-computed via circle packing
|
||
|
||
function _artMapRebuildBuffer() {
|
||
/**Render ALL nodes once to offscreen canvas. Only called on data changes, not pan/zoom.**/
|
||
const placed = _artMap.placed;
|
||
if (!placed.length) return;
|
||
|
||
const visible = placed.filter(n => (n.opacity || 0) > 0.01);
|
||
if (!visible.length) return;
|
||
|
||
// World bounds
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
visible.forEach(n => {
|
||
minX = Math.min(minX, n.x - n.radius - 10);
|
||
maxX = Math.max(maxX, n.x + n.radius + 10);
|
||
minY = Math.min(minY, n.y - n.radius - 10);
|
||
maxY = Math.max(maxY, n.y + n.radius + 10);
|
||
});
|
||
|
||
const bw = maxX - minX;
|
||
const bh = maxY - minY;
|
||
// Scale based on zoom — higher zoom = higher res buffer, capped for memory
|
||
const z = _artMap.zoom || 0.1;
|
||
const scale = Math.min(z * 2, 1.0, _artMap.MAX_BUFFER_PX / Math.max(bw, bh));
|
||
|
||
if (!_artMap.offscreen) _artMap.offscreen = document.createElement('canvas');
|
||
const oc = _artMap.offscreen;
|
||
oc.width = Math.ceil(bw * scale);
|
||
oc.height = Math.ceil(bh * scale);
|
||
const octx = oc.getContext('2d');
|
||
_artMap._bufferScale = scale;
|
||
_artMap._bufferMinX = minX;
|
||
_artMap._bufferMinY = minY;
|
||
|
||
octx.scale(scale, scale);
|
||
octx.translate(-minX, -minY);
|
||
|
||
// Build node lookup
|
||
if (!_artMap._nodeById) {
|
||
_artMap._nodeById = {};
|
||
placed.forEach(n => { _artMap._nodeById[n.id] = n; });
|
||
}
|
||
|
||
// Draw edges (connection lines between related nodes)
|
||
if (_artMap.edges && _artMap.edges.length > 0) {
|
||
octx.lineWidth = 1;
|
||
octx.strokeStyle = 'rgba(138,43,226,0.08)';
|
||
octx.beginPath();
|
||
for (const edge of _artMap.edges) {
|
||
const s = _artMap._nodeById[edge.source];
|
||
const t = _artMap._nodeById[edge.target];
|
||
if (!s || !t || (s.opacity || 0) < 0.05 || (t.opacity || 0) < 0.05) continue;
|
||
octx.moveTo(s.x, s.y);
|
||
octx.lineTo(t.x, t.y);
|
||
}
|
||
octx.stroke();
|
||
}
|
||
|
||
// Draw ALL nodes — genre labels first, similar next, watchlist on top
|
||
const hideSimilar = _artMap._hideSimilar || false;
|
||
// Pass 0: genre labels, Pass 1: similar/ring2, Pass 2: watchlist/center/ring1
|
||
for (let pass = 0; pass < 3; pass++) {
|
||
for (const n of visible) {
|
||
if (pass === 0 && n._isLabel) { /* draw */ }
|
||
else if (pass === 1 && !n._isLabel && n.type !== 'watchlist' && n.type !== 'center' && n.ring !== 1) { /* draw */ }
|
||
else if (pass === 2 && !n._isLabel && (n.type === 'watchlist' || n.type === 'center' || n.ring === 1)) { /* draw */ }
|
||
else continue;
|
||
if (hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue;
|
||
_artMapDrawNodeToBuffer(octx, n, scale);
|
||
}
|
||
}
|
||
|
||
octx.globalAlpha = 1;
|
||
|
||
_artMap.dirty = false;
|
||
}
|
||
|
||
// Draw a SINGLE node into the offscreen buffer (in world coords; caller has
|
||
// already applied the buffer's scale+translate). Shared by the full rebuild and
|
||
// the incremental image compositor so the two can never drift visually.
|
||
function _artMapDrawNodeToBuffer(octx, n, scale) {
|
||
const op = n.opacity || 0;
|
||
if (op < 0.01) return;
|
||
const r = n.radius;
|
||
const isW = n.type === 'watchlist' || n.type === 'center';
|
||
octx.globalAlpha = op;
|
||
|
||
// Genre label node — transparent circle with large text
|
||
if (n._isLabel) {
|
||
octx.globalAlpha = 0.6;
|
||
octx.beginPath();
|
||
octx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||
octx.fillStyle = 'rgba(138,43,226,0.04)';
|
||
octx.fill();
|
||
octx.strokeStyle = 'rgba(138,43,226,0.08)';
|
||
octx.lineWidth = 1;
|
||
octx.stroke();
|
||
const labelSize = Math.max(12, n.radius * 0.25);
|
||
octx.font = `800 ${labelSize}px system-ui`;
|
||
octx.textAlign = 'center';
|
||
octx.textBaseline = 'middle';
|
||
octx.fillStyle = 'rgba(138,43,226,0.35)';
|
||
octx.fillText(n.name, n.x, n.y - labelSize * 0.3);
|
||
octx.font = `500 ${labelSize * 0.5}px system-ui`;
|
||
octx.fillStyle = 'rgba(255,255,255,0.15)';
|
||
octx.fillText(`${n._count || 0} artists`, n.x, n.y + labelSize * 0.5);
|
||
octx.globalAlpha = 1;
|
||
return;
|
||
}
|
||
|
||
// Render quality based on node size in buffer pixels
|
||
const rScaled = r * scale;
|
||
const isSmall = rScaled < 8;
|
||
const isTiny = rScaled < 3;
|
||
|
||
// Tiny nodes: just a colored dot (no clip, no image, no text)
|
||
if (isTiny) {
|
||
octx.beginPath();
|
||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||
octx.fillStyle = isW ? '#6b21a8' : '#2a2a40';
|
||
octx.fill();
|
||
return;
|
||
}
|
||
|
||
// Small nodes: filled circle + border, no image clip
|
||
if (isSmall) {
|
||
octx.beginPath();
|
||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||
const img = _artMap.images[n.id];
|
||
if (img) {
|
||
octx.save(); octx.clip();
|
||
octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2);
|
||
octx.restore();
|
||
} else {
|
||
octx.fillStyle = isW ? '#1a0a30' : '#141420';
|
||
octx.fill();
|
||
}
|
||
octx.strokeStyle = isW ? 'rgba(138,43,226,0.3)' : 'rgba(255,255,255,0.06)';
|
||
octx.lineWidth = isW ? 1.5 : 0.5;
|
||
octx.stroke();
|
||
return;
|
||
}
|
||
|
||
// Full quality: glow + clip + image + text
|
||
if (isW) {
|
||
octx.beginPath();
|
||
octx.arc(n.x, n.y, r + 4, 0, Math.PI * 2);
|
||
octx.strokeStyle = 'rgba(138,43,226,0.25)';
|
||
octx.lineWidth = 5;
|
||
octx.stroke();
|
||
}
|
||
|
||
octx.save();
|
||
octx.beginPath();
|
||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||
octx.closePath();
|
||
octx.clip();
|
||
|
||
const img = _artMap.images[n.id];
|
||
if (img) {
|
||
octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2);
|
||
octx.fillStyle = 'rgba(0,0,0,0.45)';
|
||
octx.fillRect(n.x - r, n.y - r, r * 2, r * 2);
|
||
} else {
|
||
octx.fillStyle = isW ? '#1a0a30' : '#141420';
|
||
octx.fillRect(n.x - r, n.y - r, r * 2, r * 2);
|
||
}
|
||
octx.restore();
|
||
|
||
octx.beginPath();
|
||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||
octx.strokeStyle = isW ? 'rgba(138,43,226,0.4)' : 'rgba(255,255,255,0.08)';
|
||
octx.lineWidth = isW ? 2 : 0.5;
|
||
octx.stroke();
|
||
|
||
const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3);
|
||
octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`;
|
||
octx.textAlign = 'center';
|
||
octx.textBaseline = 'middle';
|
||
octx.fillStyle = '#fff';
|
||
const maxC = isW ? 20 : 12;
|
||
const label = n.name.length > maxC ? n.name.substring(0, maxC - 1) + '…' : n.name;
|
||
octx.fillText(label, n.x, n.y);
|
||
}
|
||
|
||
// Composite ONE node into the EXISTING buffer without a full rebuild. This is
|
||
// what makes image streaming cheap: when a bitmap arrives we redraw only that
|
||
// node (in place, over its placeholder) instead of redrawing all ~1500 nodes.
|
||
// Returns false if there's no buffer yet (or the node is hidden) so the caller
|
||
// can fall back to a full rebuild. Zoom changes rebuild the buffer at a new
|
||
// scale; this always reads the CURRENT buffer scale/origin, so it stays correct.
|
||
function _artMapCompositeNode(n) {
|
||
const oc = _artMap.offscreen;
|
||
const scale = _artMap._bufferScale;
|
||
if (!oc || scale == null) return false;
|
||
if (_artMap._hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) return false;
|
||
if ((n.opacity || 0) < 0.01) return false;
|
||
const octx = oc.getContext('2d');
|
||
octx.save();
|
||
octx.scale(scale, scale);
|
||
octx.translate(-_artMap._bufferMinX, -_artMap._bufferMinY);
|
||
_artMapDrawNodeToBuffer(octx, n, scale);
|
||
octx.restore();
|
||
octx.globalAlpha = 1;
|
||
return true;
|
||
}
|
||
|
||
function _artMapRender() {
|
||
// v2 perf: coalesce every render request into a single rAF, so a burst of
|
||
// mousemove/pan/animation calls never draws more than once per frame.
|
||
if (_artMap._rafPending) return;
|
||
_artMap._rafPending = requestAnimationFrame(() => {
|
||
_artMap._rafPending = null;
|
||
_artMapDraw();
|
||
});
|
||
}
|
||
|
||
function _artMapDraw() {
|
||
/**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/
|
||
const _t0 = _artMap._perf ? performance.now() : 0;
|
||
const ctx = _artMap.ctx;
|
||
const w = _artMap.width;
|
||
const h = _artMap.height;
|
||
|
||
ctx.fillStyle = '#0a0a14';
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
// Premium backdrop: a soft central glow fading to a dark vignette. The
|
||
// gradient is cached and only rebuilt on resize, so it's one cheap fillRect.
|
||
if (!_artMap._bgGrad || _artMap._bgW !== w || _artMap._bgH !== h) {
|
||
const g = ctx.createRadialGradient(w / 2, h * 0.42, Math.min(w, h) * 0.12,
|
||
w / 2, h / 2, Math.max(w, h) * 0.78);
|
||
g.addColorStop(0, 'rgba(46,34,78,0.40)');
|
||
g.addColorStop(0.5, 'rgba(16,12,28,0.0)');
|
||
g.addColorStop(1, 'rgba(0,0,0,0.55)');
|
||
_artMap._bgGrad = g; _artMap._bgW = w; _artMap._bgH = h;
|
||
}
|
||
ctx.fillStyle = _artMap._bgGrad;
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
if (_artMap.dirty || !_artMap.offscreen) {
|
||
const _rt = _artMap._perf ? performance.now() : 0;
|
||
_artMapRebuildBuffer();
|
||
if (_artMap._perf) _artMap._rebuildMs = performance.now() - _rt;
|
||
}
|
||
if (!_artMap.offscreen) { if (_artMap._perf) _artMapDrawPerf(ctx, _t0); return; }
|
||
|
||
const oc = _artMap.offscreen;
|
||
const s = _artMap._bufferScale;
|
||
const mx = _artMap._bufferMinX;
|
||
const my = _artMap._bufferMinY;
|
||
const z = _artMap.zoom;
|
||
|
||
// Blit offscreen buffer: the buffer was drawn with scale(s) + translate(-minX,-minY)
|
||
// So buffer pixel (bx,by) corresponds to world (bx/s + minX, by/s + minY)
|
||
// Screen position of world (wx,wy) = offsetX + wx*zoom, offsetY + wy*zoom
|
||
// Therefore buffer origin on screen = offsetX + minX*zoom, offsetY + minY*zoom
|
||
// And buffer is drawn at size (bufferWidth * zoom/s, bufferHeight * zoom/s)
|
||
ctx.drawImage(oc,
|
||
_artMap.offsetX + mx * z,
|
||
_artMap.offsetY + my * z,
|
||
oc.width * z / s,
|
||
oc.height * z / s
|
||
);
|
||
|
||
// ── Interactive overlay (drawn on main canvas, not buffer) ──
|
||
const cFade = _artMap._constellationFade || 0;
|
||
if (cFade > 0 && (_artMap.hoveredNode || _artMap._constellationCache)) {
|
||
const n = _artMap.hoveredNode || (_artMap._constellationCache ? (_artMap._nodeById || {})[_artMap._constellationCache.nodeId] : null);
|
||
if (!n) { _artMap._constellationFade = 0; _artMap._constellationCache = null; }
|
||
if (n) {
|
||
ctx.save();
|
||
ctx.translate(_artMap.offsetX, _artMap.offsetY);
|
||
ctx.scale(z, z);
|
||
|
||
// Cache connected node lookup (don't recompute every frame)
|
||
if (!_artMap._constellationCache || _artMap._constellationCache.nodeId !== n.id) {
|
||
const connectedIds = new Set();
|
||
if (n.type === 'watchlist') {
|
||
for (const e of _artMap.edges) {
|
||
if (e.source === n.id) connectedIds.add(e.target);
|
||
}
|
||
} else {
|
||
const sourceIds = new Set();
|
||
for (const e of _artMap.edges) {
|
||
if (e.target === n.id) sourceIds.add(e.source);
|
||
}
|
||
for (const sid of sourceIds) {
|
||
connectedIds.add(sid);
|
||
for (const e of _artMap.edges) {
|
||
if (e.source === sid) connectedIds.add(e.target);
|
||
}
|
||
}
|
||
}
|
||
const nById = _artMap._nodeById || {};
|
||
_artMap._constellationCache = {
|
||
nodeId: n.id,
|
||
nodes: [n, ...[...connectedIds].map(id => nById[id]).filter(Boolean)],
|
||
};
|
||
}
|
||
|
||
const highlightNodes = _artMap._constellationCache.nodes;
|
||
|
||
if (highlightNodes.length > 1) {
|
||
// Semi-transparent dark overlay on entire visible area
|
||
ctx.save();
|
||
ctx.resetTransform();
|
||
ctx.globalAlpha = 0.6 * cFade;
|
||
ctx.fillStyle = '#0a0a14';
|
||
ctx.fillRect(0, 0, _artMap.canvas.width, _artMap.canvas.height);
|
||
ctx.globalAlpha = 1;
|
||
ctx.restore();
|
||
|
||
// Connection lines — build the path ONCE, then two cheap strokes
|
||
// (wide faint halo + crisp core) for a glow look without per-frame
|
||
// gradients or shadowBlur (those were the hover-lag culprits).
|
||
ctx.lineCap = 'round';
|
||
ctx.beginPath();
|
||
for (const cn of highlightNodes) {
|
||
if (cn === n) continue;
|
||
ctx.moveTo(n.x, n.y);
|
||
ctx.lineTo(cn.x, cn.y);
|
||
}
|
||
ctx.strokeStyle = `rgba(168,85,247,${0.18 * cFade})`;
|
||
ctx.lineWidth = 6;
|
||
ctx.stroke();
|
||
ctx.strokeStyle = `rgba(201,150,255,${0.6 * cFade})`;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
|
||
// Redraw highlighted nodes on top
|
||
ctx.globalAlpha = cFade;
|
||
for (const hn of highlightNodes) {
|
||
const r = hn.radius;
|
||
const isW = hn.type === 'watchlist';
|
||
const isHov = hn === n;
|
||
|
||
// Glow
|
||
if (isHov) {
|
||
ctx.beginPath();
|
||
ctx.arc(hn.x, hn.y, r + 8, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(138,43,226,0.4)';
|
||
ctx.lineWidth = 6;
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Circle + image
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2);
|
||
ctx.closePath();
|
||
ctx.clip();
|
||
|
||
const img = _artMap.images[hn.id];
|
||
if (img) {
|
||
ctx.drawImage(img, hn.x - r, hn.y - r, r * 2, r * 2);
|
||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||
ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2);
|
||
} else {
|
||
ctx.fillStyle = isW ? '#1a0a30' : '#141420';
|
||
ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2);
|
||
}
|
||
ctx.restore();
|
||
|
||
// Border
|
||
ctx.beginPath();
|
||
ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2);
|
||
ctx.strokeStyle = isHov ? 'rgba(255,255,255,0.7)' : isW ? 'rgba(138,43,226,0.5)' : 'rgba(255,255,255,0.3)';
|
||
ctx.lineWidth = isHov ? 3 : 1.5;
|
||
ctx.stroke();
|
||
|
||
// Name
|
||
const fontSize = isW ? Math.max(14, r * 0.14) : Math.max(8, r * 0.3);
|
||
ctx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = '#fff';
|
||
const maxC = isW ? 20 : 12;
|
||
const label = hn.name.length > maxC ? hn.name.substring(0, maxC - 1) + '…' : hn.name;
|
||
ctx.fillText(label, hn.x, hn.y);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
} else {
|
||
// Single node, no connections
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, n.radius + 4, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||
ctx.lineWidth = 3;
|
||
ctx.stroke();
|
||
}
|
||
|
||
ctx.restore();
|
||
} // end if(n)
|
||
} else if (_artMap.hoveredNode && !_artMap._constellationActive) {
|
||
// Pre-constellation: just show a simple highlight ring (instant, no delay)
|
||
const n = _artMap.hoveredNode;
|
||
ctx.save();
|
||
ctx.translate(_artMap.offsetX, _artMap.offsetY);
|
||
ctx.scale(z, z);
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// Click ripple animation
|
||
if (_artMap._ripple) {
|
||
const rip = _artMap._ripple;
|
||
const elapsed = performance.now() - rip.start;
|
||
const progress = elapsed / 400; // 400ms duration
|
||
if (progress < 1) {
|
||
ctx.save();
|
||
ctx.translate(_artMap.offsetX, _artMap.offsetY);
|
||
ctx.scale(z, z);
|
||
const ripR = rip.radius + rip.radius * progress * 0.5;
|
||
ctx.beginPath();
|
||
ctx.arc(rip.x, rip.y, ripR, 0, Math.PI * 2);
|
||
ctx.strokeStyle = `rgba(138,43,226,${0.5 * (1 - progress)})`;
|
||
ctx.lineWidth = 3 * (1 - progress);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
requestAnimationFrame(() => _artMapRender());
|
||
} else {
|
||
_artMap._ripple = null;
|
||
}
|
||
}
|
||
|
||
if (_artMap._perf) _artMapDrawPerf(ctx, _t0);
|
||
}
|
||
|
||
// Toggle with 'd' on the map. Shows where frame time goes so we optimise the
|
||
// real bottleneck (buffer rebuild on zoom vs. blit on pan) instead of guessing.
|
||
function _artMapDrawPerf(ctx, t0) {
|
||
const drawMs = performance.now() - t0;
|
||
const now = performance.now();
|
||
const dt = _artMap._lastPerfTs ? now - _artMap._lastPerfTs : 0;
|
||
_artMap._lastPerfTs = now;
|
||
const fps = dt > 0 ? Math.round(1000 / dt) : 0;
|
||
const oc = _artMap.offscreen;
|
||
|
||
// Ship the numbers to app.log (~1.5/s) so they can be read server-side —
|
||
// the on-canvas text below can't be copied, especially mid-lag.
|
||
if (!_artMap._perfPostTs || now - _artMap._perfPostTs > 700) {
|
||
_artMap._perfPostTs = now;
|
||
try {
|
||
fetch('/api/discover/artist-map/perf', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
nodes: _artMap.placed.length, edges: (_artMap.edges || []).length,
|
||
buffer: oc ? oc.width + 'x' + oc.height : '-',
|
||
scale: +(_artMap._bufferScale || 0).toFixed(3),
|
||
zoom: +_artMap.zoom.toFixed(3),
|
||
rebuildMs: +(_artMap._rebuildMs || 0).toFixed(1),
|
||
drawMs: +drawMs.toFixed(1), fps,
|
||
}),
|
||
}).catch(() => { });
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
const lines = [
|
||
`nodes ${_artMap.placed.length} edges ${(_artMap.edges || []).length}`,
|
||
`buffer ${oc ? oc.width + '×' + oc.height : '—'} scale ${(_artMap._bufferScale || 0).toFixed(3)}`,
|
||
`zoom ${_artMap.zoom.toFixed(3)}`,
|
||
`rebuild ${(_artMap._rebuildMs || 0).toFixed(1)}ms draw ${drawMs.toFixed(1)}ms`,
|
||
`~${fps} fps (while interacting)`,
|
||
];
|
||
ctx.save();
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0); // device pixels, ignore dpr scale
|
||
ctx.font = '12px monospace';
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
const pad = 8, lh = 16;
|
||
ctx.fillStyle = 'rgba(0,0,0,0.72)';
|
||
ctx.fillRect(10, 10, 270, lines.length * lh + pad * 2);
|
||
ctx.fillStyle = '#7CFC00';
|
||
lines.forEach((l, i) => ctx.fillText(l, 10 + pad, 10 + pad + i * lh));
|
||
ctx.restore();
|
||
}
|
||
|
||
function artMapSearch(query) {
|
||
const results = document.getElementById('artist-map-search-results');
|
||
if (!results) return;
|
||
if (!query || query.length < 2) { results.style.display = 'none'; return; }
|
||
|
||
const q = query.toLowerCase();
|
||
const matches = _artMap.placed.filter(n => (n.opacity || 0) > 0.5 && n.name.toLowerCase().includes(q)).slice(0, 8);
|
||
|
||
if (!matches.length) { results.style.display = 'none'; return; }
|
||
|
||
results.style.display = 'block';
|
||
results.innerHTML = matches.map(n =>
|
||
`<div class="artist-map-search-item" onclick="artMapZoomToNode(${n.id})">
|
||
<span class="artist-map-search-type ${n.type}">${n.type === 'watchlist' ? '★' : '○'}</span>
|
||
${escapeHtml(n.name)}
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
function artMapZoomToNode(nodeId) {
|
||
const n = _artMap.placed.find(p => p.id === nodeId);
|
||
if (!n) return;
|
||
// Zoom to show this node centered, at a comfortable zoom level
|
||
const targetZoom = Math.max(0.3, Math.min(1, 200 / n.radius));
|
||
const targetOX = _artMap.width / 2 - n.x * targetZoom;
|
||
const targetOY = _artMap.height / 2 - n.y * targetZoom;
|
||
_artMapAnimateTo(targetZoom, targetOX, targetOY);
|
||
// Highlight briefly after animation
|
||
setTimeout(() => { _artMap.hoveredNode = n; _artMapRender(); }, 300);
|
||
setTimeout(() => { _artMap.hoveredNode = null; _artMapRender(); }, 2500);
|
||
// Close search
|
||
const results = document.getElementById('artist-map-search-results');
|
||
if (results) results.style.display = 'none';
|
||
const input = document.getElementById('artist-map-search');
|
||
if (input) input.value = '';
|
||
}
|
||
|
||
function _artMapShowTooltip(e, node) {
|
||
const tip = document.getElementById('artist-map-tooltip');
|
||
if (!tip) return;
|
||
if (!node) { tip.style.display = 'none'; _artMap._tipNodeId = null; return; }
|
||
|
||
// v2 perf: only rebuild the tooltip's innerHTML (and reload its image) when
|
||
// the hovered artist actually changes — a plain mousemove just repositions.
|
||
if (_artMap._tipNodeId !== node.id) {
|
||
_artMap._tipNodeId = node.id;
|
||
const img = node.image_url ? `<img class="artmap-tip-img" src="${escapeHtml(node.image_url)}" alt="">` : '<div class="artmap-tip-img artmap-tip-img-fallback">♫</div>';
|
||
const genres = (node.genres || []).slice(0, 3);
|
||
const genreHTML = genres.length ? `<div class="artmap-tip-genres">${genres.map(g => `<span>${escapeHtml(g)}</span>`).join('')}</div>` : '';
|
||
const typeLabel = node.type === 'watchlist' ? '<span class="artmap-tip-badge">★ Watchlist</span>' : '';
|
||
// Real connection count from the map's edges (cheap; only on hover change).
|
||
let conn = 0;
|
||
const edges = _artMap.edges || [];
|
||
for (const ed of edges) { if (ed.source === node.id || ed.target === node.id) conn++; }
|
||
const connHTML = conn ? `<div class="artmap-tip-conn">${conn} connection${conn === 1 ? '' : 's'}</div>` : '';
|
||
tip.innerHTML = `
|
||
<div class="artmap-tip-row">
|
||
${img}
|
||
<div class="artmap-tip-info">
|
||
<div class="artmap-tip-name">${escapeHtml(node.name)}</div>
|
||
${typeLabel}
|
||
${connHTML}
|
||
${genreHTML}
|
||
</div>
|
||
</div>
|
||
`;
|
||
tip.style.display = 'block';
|
||
}
|
||
|
||
// Position — keep on screen (cheap; runs every move)
|
||
const x = Math.min(e.clientX + 16, window.innerWidth - tip.offsetWidth - 10);
|
||
const y = Math.min(e.clientY - 10, window.innerHeight - tip.offsetHeight - 10);
|
||
tip.style.left = x + 'px';
|
||
tip.style.top = y + 'px';
|
||
}
|
||
|
||
function _artMapAnimateConstellation() {
|
||
if (_artMap._constellationActive && _artMap._constellationFade < 1) {
|
||
_artMap._constellationFade = Math.min(1, (_artMap._constellationFade || 0) + 0.08);
|
||
_artMapRender();
|
||
requestAnimationFrame(_artMapAnimateConstellation);
|
||
} else if (!_artMap._constellationActive && _artMap._constellationFade > 0) {
|
||
_artMap._constellationFade = Math.max(0, _artMap._constellationFade - 0.1);
|
||
_artMapRender();
|
||
if (_artMap._constellationFade > 0) {
|
||
requestAnimationFrame(_artMapAnimateConstellation);
|
||
} else {
|
||
_artMap._constellationCache = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
function artMapShowShortcuts() {
|
||
const existing = document.getElementById('artmap-shortcuts-overlay');
|
||
if (existing) { existing.remove(); return; }
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'artmap-shortcuts-overlay';
|
||
overlay.className = 'modal-overlay';
|
||
overlay.style.zIndex = '10002';
|
||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||
|
||
overlay.innerHTML = `
|
||
<div class="artmap-shortcuts-modal">
|
||
<div class="artmap-shortcuts-header">
|
||
<h3>Keyboard Shortcuts</h3>
|
||
<button class="watch-all-close" onclick="document.getElementById('artmap-shortcuts-overlay').remove()">×</button>
|
||
</div>
|
||
<div class="artmap-shortcuts-grid">
|
||
<div class="artmap-shortcut"><kbd>Esc</kbd><span>Close map</span></div>
|
||
<div class="artmap-shortcut"><kbd>+</kbd> / <kbd>-</kbd><span>Zoom in / out</span></div>
|
||
<div class="artmap-shortcut"><kbd>F</kbd><span>Fit to view</span></div>
|
||
<div class="artmap-shortcut"><kbd>S</kbd><span>Focus search</span></div>
|
||
<div class="artmap-shortcut"><kbd>H</kbd><span>Toggle similar artists</span></div>
|
||
<div class="artmap-shortcut"><kbd>Scroll</kbd><span>Zoom at cursor</span></div>
|
||
<div class="artmap-shortcut"><kbd>Click</kbd><span>Artist info</span></div>
|
||
<div class="artmap-shortcut"><kbd>Right-click</kbd><span>Context menu</span></div>
|
||
<div class="artmap-shortcut"><kbd>Drag</kbd><span>Pan around</span></div>
|
||
<div class="artmap-shortcut"><kbd>Hover 1s</kbd><span>Show connections</span></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
async function openArtistMapGenre() {
|
||
// Show picker immediately — uses lightweight genre list endpoint
|
||
const genre = await _showGenrePickerModal();
|
||
if (!genre) return;
|
||
_openGenreMapWithSelection(genre);
|
||
}
|
||
|
||
async function _showGenrePickerModal() {
|
||
return new Promise(resolve => {
|
||
const existing = document.getElementById('artmap-genre-picker');
|
||
if (existing) existing.remove();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'artmap-genre-picker';
|
||
overlay.className = 'modal-overlay';
|
||
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
|
||
|
||
overlay.innerHTML = `
|
||
<div class="artmap-genre-picker-modal">
|
||
<div class="artmap-genre-picker-header">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><line x1="2" y1="12" x2="22" y2="12"/>
|
||
</svg>
|
||
<div>
|
||
<h3>Select a Genre</h3>
|
||
<p>Choose a genre to explore its artists</p>
|
||
</div>
|
||
</div>
|
||
<input type="text" class="artmap-genre-picker-search" placeholder="Filter genres..." oninput="_filterGenrePicker(this.value)">
|
||
<div class="artmap-genre-picker-list" id="artmap-genre-picker-list">
|
||
<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Loading genres...</div></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
|
||
// Use cached data or fetch
|
||
const renderGenreList = (data) => {
|
||
if (!data?.success || !data?.genres?.length) {
|
||
document.getElementById('artmap-genre-picker-list').innerHTML = '<div class="ya-info-empty">No genres found</div>';
|
||
return;
|
||
}
|
||
const list = document.getElementById('artmap-genre-picker-list');
|
||
list.innerHTML = data.genres.map(g => `
|
||
<div class="artmap-genre-picker-item" data-genre="${escapeHtml(g.name)}" onclick="document.getElementById('artmap-genre-picker')._resolve('${escapeForInlineJs(g.name)}')">
|
||
<div class="artmap-genre-picker-name">${escapeHtml(g.name)}</div>
|
||
<div class="artmap-genre-picker-count">${g.count} artists</div>
|
||
</div>
|
||
`).join('');
|
||
};
|
||
|
||
if (window._artMapGenreList) {
|
||
renderGenreList(window._artMapGenreList);
|
||
} else {
|
||
fetch('/api/discover/artist-map/genre-list')
|
||
.then(r => r.json())
|
||
.then(data => { window._artMapGenreList = data; renderGenreList(data); })
|
||
.catch(() => { document.getElementById('artmap-genre-picker-list').innerHTML = '<div class="ya-info-empty">Error loading genres</div>'; });
|
||
}
|
||
|
||
overlay._resolve = (genre) => { overlay.remove(); resolve(genre); };
|
||
});
|
||
}
|
||
|
||
function _switchGenre(genre) {
|
||
_artMap._skipSectionToggle = true;
|
||
_openGenreMapWithSelection(genre);
|
||
}
|
||
|
||
function _filterGenreSidebar(query) {
|
||
const q = query.toLowerCase();
|
||
document.querySelectorAll('.artmap-genre-sidebar-item').forEach(el => {
|
||
el.style.display = el.dataset.genre.toLowerCase().includes(q) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
async function _changeGenre() {
|
||
const genre = await _showGenrePickerModal();
|
||
if (!genre) return;
|
||
_artMap._skipSectionToggle = true;
|
||
_openGenreMapWithSelection(genre);
|
||
}
|
||
|
||
function _filterGenrePicker(query) {
|
||
const q = query.toLowerCase();
|
||
document.querySelectorAll('.artmap-genre-picker-item').forEach(el => {
|
||
el.style.display = el.dataset.genre.toLowerCase().includes(q) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
async function _openGenreMapWithSelection(selectedGenre) {
|
||
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';
|
||
|
||
// Show + populate genre sidebar
|
||
const sidebar = document.getElementById('artmap-genre-sidebar');
|
||
const genreListData = window._artMapGenreList || window._artMapGenreData;
|
||
if (sidebar && genreListData?.genres) {
|
||
sidebar.style.display = 'flex';
|
||
const list = document.getElementById('artmap-genre-sidebar-list');
|
||
if (list) {
|
||
list.innerHTML = genreListData.genres.map(g => `
|
||
<div class="artmap-genre-sidebar-item ${g.name === selectedGenre ? 'active' : ''}"
|
||
data-genre="${escapeHtml(g.name)}"
|
||
onclick="_switchGenre('${escapeForInlineJs(g.name)}')">
|
||
<span class="artmap-genre-sidebar-name">${escapeHtml(g.name)}</span>
|
||
<span class="artmap-genre-sidebar-count">${g.count}</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
}
|
||
|
||
const canvas = document.getElementById('artist-map-canvas');
|
||
const contentRow = canvas.parentElement;
|
||
_artMap.canvas = canvas;
|
||
_artMap.ctx = canvas.getContext('2d');
|
||
_artMap.width = canvas.clientWidth || (container.clientWidth - (sidebar?.offsetWidth || 0));
|
||
_artMap.height = contentRow?.clientHeight || (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;
|
||
|
||
// Show loading
|
||
const loadingEl = document.createElement('div');
|
||
loadingEl.id = 'artist-map-loading';
|
||
loadingEl.innerHTML = `<div class="artist-map-loading-content"><div class="watch-all-loading-spinner"></div><div class="artist-map-loading-text" id="artmap-genre-loading-text">Loading genre map...</div></div>`;
|
||
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 =
|
||
`<span class="artmap-genre-change" onclick="event.stopPropagation(); _changeGenre()" title="Change genre">${escapeHtml(selectedGenre)} ▾</span> · ${genres.length} genre${genres.length > 1 ? 's' : ''} · ${totalArtists} artists`;
|
||
|
||
const WR = _artMap.WATCHLIST_R;
|
||
const BUF = _artMap.BUFFER;
|
||
|
||
const maxPerGenre = 500;
|
||
const nodeR = WR * 0.2;
|
||
|
||
// Calculate actual cluster radius for each genre based on ring count
|
||
function getClusterRadius(artistCount) {
|
||
const count = Math.min(artistCount, maxPerGenre);
|
||
let ringDist = WR + nodeR * 2 + BUF;
|
||
let placed = 0;
|
||
while (placed < count) {
|
||
const circ = 2 * Math.PI * ringDist;
|
||
const inRing = Math.max(1, Math.floor(circ / (nodeR * 2 + BUF)));
|
||
placed += Math.min(inRing, count - placed);
|
||
ringDist += nodeR * 2 + BUF;
|
||
}
|
||
return ringDist;
|
||
}
|
||
|
||
// Pre-compute cluster radii
|
||
genres.forEach(g => { g._clusterR = getClusterRadius(g.artist_ids.length); });
|
||
|
||
// Golden spiral placement
|
||
genres.forEach((g, i) => {
|
||
if (i === 0) { g._cx = 0; g._cy = 0; }
|
||
else {
|
||
const angle = i * 2.399963;
|
||
const r = g._clusterR * Math.sqrt(i) * 0.9;
|
||
g._cx = Math.cos(angle) * r;
|
||
g._cy = Math.sin(angle) * r;
|
||
}
|
||
});
|
||
|
||
// Push apart using actual cluster radii — no overlap possible
|
||
for (let pass = 0; pass < 80; pass++) {
|
||
let moved = false;
|
||
for (let i = 0; i < genres.length; i++) {
|
||
for (let j = i + 1; j < genres.length; j++) {
|
||
const dx = genres[j]._cx - genres[i]._cx;
|
||
const dy = genres[j]._cy - genres[i]._cy;
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const minDist = genres[i]._clusterR + genres[j]._clusterR + BUF * 4;
|
||
if (dist < minDist) {
|
||
const push = (minDist - dist) / 2 + 1;
|
||
genres[i]._cx -= (dx / dist) * push; genres[i]._cy -= (dy / dist) * push;
|
||
genres[j]._cx += (dx / dist) * push; genres[j]._cy += (dy / dist) * push;
|
||
moved = true;
|
||
}
|
||
}
|
||
}
|
||
if (!moved) break;
|
||
}
|
||
|
||
let placedCount = 0;
|
||
|
||
// Place genre labels as big watchlist-style bubbles
|
||
for (const g of genres) {
|
||
_artMap.placed.push({
|
||
id: `genre_${g.name}`, name: g.name.toUpperCase(),
|
||
x: g._cx, y: g._cy, radius: WR, opacity: 1,
|
||
type: 'genre_label', image_url: '', genres: [g.name],
|
||
_isLabel: true, _count: g.count
|
||
});
|
||
}
|
||
|
||
// Place artists in concentric rings — deterministic O(1) per node, handles 10K+ instantly
|
||
let genreIdx = 0;
|
||
|
||
async function placeGenreArtists() {
|
||
for (; genreIdx < genres.length; genreIdx++) {
|
||
const genre = genres[genreIdx];
|
||
const artists = genre.artist_ids.slice(0, maxPerGenre);
|
||
const sorted = artists.map(nid => data.nodes[nid]).filter(Boolean).sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
|
||
|
||
let ringDist = WR + nodeR * 2 + BUF;
|
||
let ringNum = 0;
|
||
let placed = 0;
|
||
|
||
while (placed < sorted.length) {
|
||
const circumference = 2 * Math.PI * ringDist;
|
||
const nodesInRing = Math.max(1, Math.floor(circumference / (nodeR * 2 + BUF)));
|
||
const count = Math.min(nodesInRing, sorted.length - placed);
|
||
const angleStep = (2 * Math.PI) / nodesInRing;
|
||
const angleOffset = ringNum * 0.618;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const n = sorted[placed + i];
|
||
if (!n) continue;
|
||
const isW = n.type === 'watchlist' || n.type === 'center';
|
||
const r = isW ? nodeR * 1.5 : nodeR;
|
||
const angle = angleOffset + i * angleStep;
|
||
|
||
_artMap.placed.push({
|
||
id: placedCount + 1000, _origId: n.id, name: n.name,
|
||
x: genre._cx + Math.cos(angle) * ringDist,
|
||
y: genre._cy + Math.sin(angle) * ringDist,
|
||
radius: r, opacity: 1,
|
||
type: isW ? 'watchlist' : 'similar',
|
||
image_url: n.image_url || '', genres: n.genres || [],
|
||
spotify_id: n.spotify_id || '', itunes_id: n.itunes_id || '',
|
||
deezer_id: n.deezer_id || '', discogs_id: n.discogs_id || '',
|
||
});
|
||
placedCount++;
|
||
}
|
||
placed += count;
|
||
ringDist += nodeR * 2 + BUF;
|
||
ringNum++;
|
||
}
|
||
|
||
if (loadingText) loadingText.textContent = `Placing artists... ${genreIdx + 1}/${genres.length} genres (${placedCount} placed)`;
|
||
if (genreIdx % 5 === 0) await new Promise(r => setTimeout(r, 0));
|
||
}
|
||
}
|
||
await placeGenreArtists();
|
||
|
||
// Build edges: connect artists that appear in multiple genre clusters
|
||
_artMap.edges = [];
|
||
const artistNodes = {};
|
||
_artMap.placed.forEach(n => {
|
||
if (n._origId != null) {
|
||
if (!artistNodes[n._origId]) artistNodes[n._origId] = [];
|
||
artistNodes[n._origId].push(n.id);
|
||
}
|
||
});
|
||
Object.values(artistNodes).forEach(ids => {
|
||
if (ids.length > 1) {
|
||
for (let i = 0; i < ids.length - 1; i++) {
|
||
_artMap.edges.push({ source: ids[i], target: ids[i + 1], weight: 5 });
|
||
}
|
||
}
|
||
});
|
||
|
||
_artMap._nodeById = {};
|
||
_artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; });
|
||
|
||
// Auto-zoom
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
_artMap.placed.forEach(n => {
|
||
minX = Math.min(minX, n.x - n.radius);
|
||
maxX = Math.max(maxX, n.x + n.radius);
|
||
minY = Math.min(minY, n.y - n.radius);
|
||
maxY = Math.max(maxY, n.y + n.radius);
|
||
});
|
||
const mapW = maxX - minX + 200, mapH = maxY - minY + 200;
|
||
_artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1);
|
||
_artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom;
|
||
_artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom;
|
||
|
||
_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;
|
||
_artMapRender();
|
||
|
||
// 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 = `<div class="artist-map-loading-content"><div class="watch-all-loading-spinner"></div><div class="artist-map-loading-text">Exploring ${escapeHtml(name)}...</div></div>`;
|
||
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`;
|
||
|
||
_artMap.edges = data.edges;
|
||
const WR = _artMap.WATCHLIST_R;
|
||
const BUF = _artMap.BUFFER;
|
||
|
||
// Layout: center node at origin, ring 1 in circle around it, ring 2 around ring 1
|
||
const centerNode = data.nodes[0];
|
||
centerNode.x = 0; centerNode.y = 0;
|
||
centerNode.radius = WR * 1.2; // Extra large center
|
||
centerNode.opacity = 1;
|
||
centerNode.type = 'center';
|
||
_artMap.placed.push(centerNode);
|
||
|
||
const CELL = WR * 2 + BUF * 2;
|
||
const grid = {};
|
||
function _gk(x, y) { return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`; }
|
||
function _ga(n) { const k = _gk(n.x, n.y); if (!grid[k]) grid[k] = []; grid[k].push(n); }
|
||
function _gc(x, y, r) {
|
||
const cx = Math.floor(x / CELL), cy = Math.floor(y / CELL);
|
||
for (let dx = -3; dx <= 3; dx++) for (let dy = -3; dy <= 3; dy++) {
|
||
const cell = grid[`${cx + dx},${cy + dy}`];
|
||
if (!cell) continue;
|
||
for (const p of cell) {
|
||
const ddx = x - p.x, ddy = y - p.y;
|
||
if (ddx * ddx + ddy * ddy < (r + p.radius + BUF) * (r + p.radius + BUF)) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
_ga(centerNode);
|
||
|
||
// Place ring 1 in a circle
|
||
const ring1 = data.nodes.filter(n => n.ring === 1);
|
||
const ring1Dist = WR * 2.5;
|
||
ring1.forEach((n, i) => {
|
||
const angle = (i / ring1.length) * Math.PI * 2;
|
||
const rank = n.rank || 5;
|
||
n.radius = WR * 0.4 + (10 - rank) * WR * 0.03;
|
||
n.opacity = 1;
|
||
|
||
// Find non-colliding position near ideal
|
||
let placed = false;
|
||
for (let dist = ring1Dist; dist < ring1Dist + WR * 3; dist += n.radius * 0.5) {
|
||
for (let ao = 0; ao < 6; ao++) {
|
||
const a = angle + (ao * 0.1 * (ao % 2 ? 1 : -1));
|
||
const tx = Math.cos(a) * dist;
|
||
const ty = Math.sin(a) * dist;
|
||
if (!_gc(tx, ty, n.radius)) {
|
||
n.x = tx; n.y = ty;
|
||
_artMap.placed.push(n);
|
||
_ga(n);
|
||
placed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (placed) break;
|
||
}
|
||
});
|
||
|
||
// Place ring 2 around their ring 1 sources
|
||
const ring2 = data.nodes.filter(n => n.ring === 2);
|
||
const nodeById = {};
|
||
_artMap.placed.forEach(n => { nodeById[n.id] = n; });
|
||
|
||
ring2.forEach(n => {
|
||
// Find the ring 1 node that connects to this
|
||
const edge = data.edges.find(e => e.target === n.id);
|
||
const src = edge ? nodeById[edge.source] : null;
|
||
const cx = src ? src.x : 0;
|
||
const cy = src ? src.y : 0;
|
||
|
||
n.radius = WR * 0.2 + (n.popularity || 0) / 100 * WR * 0.1;
|
||
n.opacity = 1;
|
||
|
||
const startDist = (src ? src.radius : WR) + n.radius + BUF;
|
||
let placed = false;
|
||
for (let dist = startDist; dist < startDist + WR * 2; dist += n.radius * 0.5) {
|
||
const steps = Math.max(8, Math.floor(dist * 0.08));
|
||
const off = Math.random() * Math.PI * 2;
|
||
for (let a = 0; a < steps; a++) {
|
||
const angle = off + (a / steps) * Math.PI * 2;
|
||
const tx = cx + Math.cos(angle) * dist;
|
||
const ty = cy + Math.sin(angle) * dist;
|
||
if (!_gc(tx, ty, n.radius)) {
|
||
n.x = tx; n.y = ty;
|
||
_artMap.placed.push(n);
|
||
_ga(n);
|
||
placed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (placed) break;
|
||
}
|
||
});
|
||
|
||
// Build node lookup for edges
|
||
_artMap._nodeById = {};
|
||
_artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; });
|
||
|
||
// Auto-zoom
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
_artMap.placed.forEach(n => {
|
||
minX = Math.min(minX, n.x - n.radius);
|
||
maxX = Math.max(maxX, n.x + n.radius);
|
||
minY = Math.min(minY, n.y - n.radius);
|
||
maxY = Math.max(maxY, n.y + n.radius);
|
||
});
|
||
const mapW = maxX - minX + 200, mapH = maxY - minY + 200;
|
||
_artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1);
|
||
_artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom;
|
||
_artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom;
|
||
|
||
_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;
|
||
_artMapRender();
|
||
|
||
// 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 = `
|
||
<div class="artmap-search-prompt-modal">
|
||
<div class="artmap-search-prompt-header">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||
<line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/>
|
||
</svg>
|
||
<div>
|
||
<h3>Artist Explorer</h3>
|
||
<p>Search and pick an artist to explore</p>
|
||
</div>
|
||
</div>
|
||
<div class="artmap-explore-search-wrap">
|
||
<input type="text" id="artmap-explore-input" class="artmap-explore-input"
|
||
placeholder="Search artists…" autocomplete="off" autofocus>
|
||
<div class="artmap-explore-spinner" id="artmap-explore-spinner" style="display:none">
|
||
<div class="watch-all-loading-spinner"></div>
|
||
</div>
|
||
</div>
|
||
<div class="artmap-explore-results" id="artmap-explore-results"></div>
|
||
<div class="artmap-search-prompt-actions">
|
||
<button class="btn btn--sm btn--secondary ya-header-btn" id="artmap-explore-cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
|
||
const input = overlay.querySelector('#artmap-explore-input');
|
||
const results = overlay.querySelector('#artmap-explore-results');
|
||
const spinner = overlay.querySelector('#artmap-explore-spinner');
|
||
overlay.querySelector('#artmap-explore-cancel').onclick = () => finish(null);
|
||
|
||
const renderResults = (artists) => {
|
||
results.innerHTML = '';
|
||
if (!artists.length) {
|
||
results.innerHTML = '<div class="artmap-explore-empty">No artists found</div>';
|
||
return;
|
||
}
|
||
artists.forEach(a => {
|
||
const img = a.image_url || '/static/placeholder-album.png';
|
||
const row = document.createElement('div');
|
||
row.className = 'artmap-explore-result';
|
||
row.innerHTML = `
|
||
<img src="${escapeHtml(img)}" alt="" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
|
||
<span class="artmap-explore-result-name">${escapeHtml(a.name)}</span>
|
||
<span class="artmap-explore-result-go">Explore →</span>`;
|
||
row.onclick = () => finish(a.name); // pick the resolved artist, not raw text
|
||
results.appendChild(row);
|
||
});
|
||
};
|
||
|
||
let timer = null;
|
||
let token = 0;
|
||
const doSearch = () => {
|
||
const q = input.value.trim();
|
||
if (!q) { results.innerHTML = ''; spinner.style.display = 'none'; clearTimeout(timer); return; }
|
||
clearTimeout(timer);
|
||
timer = setTimeout(async () => {
|
||
const myToken = ++token;
|
||
spinner.style.display = 'flex';
|
||
try {
|
||
const resp = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(q)}`);
|
||
const data = await resp.json();
|
||
if (myToken !== token) return; // a newer keystroke superseded this
|
||
renderResults((data && data.success && Array.isArray(data.artists)) ? data.artists : []);
|
||
} catch (e) {
|
||
if (myToken === token) results.innerHTML = '<div class="artmap-explore-empty">Search failed — try again</div>';
|
||
} finally {
|
||
if (myToken === token) spinner.style.display = 'none';
|
||
}
|
||
}, 350);
|
||
};
|
||
|
||
input.addEventListener('input', doSearch);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
const first = results.querySelector('.artmap-explore-result');
|
||
if (first) first.click(); // Enter = pick top match, never raw text
|
||
} else if (e.key === 'Escape') {
|
||
finish(null);
|
||
}
|
||
});
|
||
setTimeout(() => input.focus(), 50);
|
||
});
|
||
}
|
||
|
||
function artMapToggleSimilar() {
|
||
_artMap._hideSimilar = !_artMap._hideSimilar;
|
||
_artMap.dirty = true;
|
||
_artMapRender();
|
||
const btn = document.getElementById('artmap-toggle-similar');
|
||
if (btn) btn.style.opacity = _artMap._hideSimilar ? '0.4' : '1';
|
||
showToast(_artMap._hideSimilar ? 'Showing watchlist only' : 'Showing all artists', 'info', 1500);
|
||
}
|
||
|
||
// Artist images come in at up to 1000×1000. Nodes are drawn tiny, so holding
|
||
// full-res bitmaps is pointless and ruinous: ~1500 nodes × 1000² × 4 bytes ≈ 6 GB
|
||
// of decoded image memory → GC/GPU thrash that locks the browser even though the
|
||
// per-frame draw is cheap. Decode straight to a small avatar (~128px) so the
|
||
// whole map's images fit in ~100 MB instead of gigabytes.
|
||
// Decode to a sensible avatar size for how big the node actually draws — crisp
|
||
// where it matters (focal/watchlist nodes), light for the swarm of small ones —
|
||
// so total image memory stays ~150-250 MB instead of multiple GB.
|
||
function _artMapImgPx(px) {
|
||
return Math.min(384, Math.max(112, Math.round(px || 144)));
|
||
}
|
||
function _artMapDecodeSmall(blob, px) {
|
||
if (!blob) return Promise.resolve(null);
|
||
const d = _artMapImgPx(px);
|
||
try {
|
||
return createImageBitmap(blob, {
|
||
resizeWidth: d, resizeHeight: d, resizeQuality: 'high',
|
||
}).catch(() => createImageBitmap(blob).catch(() => null)); // older engines ignore opts
|
||
} catch (e) {
|
||
return createImageBitmap(blob).catch(() => null);
|
||
}
|
||
}
|
||
|
||
function _artMapLoadImage(url, px) {
|
||
// Try direct CORS fetch first (zero server load, works for Spotify/iTunes/Discogs)
|
||
return fetch(url, { mode: 'cors' })
|
||
.then(r => r.ok ? r.blob() : Promise.reject('not ok'))
|
||
.then(b => _artMapDecodeSmall(b, px))
|
||
.catch(() => {
|
||
// Fallback: server proxy for CDNs without CORS headers
|
||
return fetch('/api/image-proxy?url=' + encodeURIComponent(url))
|
||
.then(r => r.ok ? r.blob() : null)
|
||
.then(b => _artMapDecodeSmall(b, px))
|
||
.catch(() => null);
|
||
});
|
||
}
|
||
|
||
// Target avatar px for a node, based on its world radius (≈ its on-screen size
|
||
// at full zoom). Focal/watchlist nodes get a big crisp avatar; small ones stay
|
||
// light. Used by every map's image loader.
|
||
function _artMapNodeImgPx(n) {
|
||
const isFocal = n.type === 'watchlist' || n.type === 'center' || n.ring === 1;
|
||
return _artMapImgPx(isFocal ? Math.max(256, (n.radius || 0) * 1.4) : (n.radius || 0) * 1.6);
|
||
}
|
||
|
||
// Stream node images in the background WITHOUT blocking the first paint. The map
|
||
// is drawn immediately with placeholder circles and stays fully interactive
|
||
// (click/hover/pan) while images fill in. Redraws are throttled into ~waves so
|
||
// 1000s of arrivals don't trigger 1000s of buffer rebuilds. A load token makes
|
||
// opening another map cancel this stream (stale bitmaps are dropped). Focal
|
||
// nodes are fetched first so what you're looking at sharpens soonest.
|
||
function _artMapStreamImages(imgNodes, concurrent = 24) {
|
||
const token = (_artMap._loadToken = (_artMap._loadToken || 0) + 1);
|
||
// Focal/large nodes first — the user's eye lands there.
|
||
const queue = imgNodes.filter(n => n.image_url).slice().sort((a, b) => (b.radius || 0) - (a.radius || 0));
|
||
let idx = 0, inFlight = 0, redrawPending = false;
|
||
|
||
// Fallback full-rebuild path, used only if the buffer isn't ready yet
|
||
// (e.g. images returned before the first paint built the offscreen buffer).
|
||
const scheduleRedraw = () => {
|
||
if (redrawPending || token !== _artMap._loadToken) return;
|
||
redrawPending = true;
|
||
setTimeout(() => {
|
||
redrawPending = false;
|
||
if (token !== _artMap._loadToken) return;
|
||
_artMap.dirty = true;
|
||
_artMapRender();
|
||
}, 280);
|
||
};
|
||
|
||
function pump() {
|
||
if (token !== _artMap._loadToken) return; // a newer map took over
|
||
while (inFlight < concurrent && idx < queue.length) {
|
||
const n = queue[idx++];
|
||
if (_artMap.images[n.id]) continue;
|
||
inFlight++;
|
||
_artMapLoadImage(n.image_url, _artMapNodeImgPx(n))
|
||
.then(bmp => {
|
||
if (bmp && token === _artMap._loadToken) {
|
||
_artMap.images[n.id] = bmp;
|
||
// Composite just this node into the existing buffer (cheap)
|
||
// and blit. Only fall back to a full rebuild if the buffer
|
||
// isn't built yet. This keeps pan/hover at blit-speed the
|
||
// whole time images are streaming in — the lag fix.
|
||
if (_artMapCompositeNode(n)) _artMapRender();
|
||
else scheduleRedraw();
|
||
}
|
||
})
|
||
.finally(() => { inFlight--; pump(); });
|
||
}
|
||
}
|
||
pump();
|
||
}
|
||
|
||
function _artMapHideContextMenu() {
|
||
const m = document.getElementById('artist-map-context');
|
||
if (m) m.style.display = 'none';
|
||
}
|
||
|
||
function _artMapSetupInteraction(canvas) {
|
||
// Prevent stacking listeners on repeated opens
|
||
if (canvas._artMapListenersAttached) return;
|
||
canvas._artMapListenersAttached = true;
|
||
|
||
let isPanning = false, panStartX = 0, panStartY = 0;
|
||
|
||
canvas.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||
const newZoom = Math.max(0.02, Math.min(5, _artMap.zoom * delta));
|
||
// Zoom toward mouse
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mx = e.clientX - rect.left;
|
||
const my = e.clientY - rect.top;
|
||
_artMap.offsetX = mx - (mx - _artMap.offsetX) * (newZoom / _artMap.zoom);
|
||
_artMap.offsetY = my - (my - _artMap.offsetY) * (newZoom / _artMap.zoom);
|
||
_artMap.zoom = newZoom;
|
||
_artMapRender(); // fast blit
|
||
// Debounce hi-res rebuild after zoom settles
|
||
clearTimeout(_artMap._zoomRebuild);
|
||
_artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); }, 300);
|
||
}, { passive: false });
|
||
|
||
let clickStart = null;
|
||
|
||
// Keyboard shortcuts
|
||
function _artMapKeyHandler(e) {
|
||
if (!document.getElementById('artist-map-container') || document.getElementById('artist-map-container').style.display === 'none') return;
|
||
if (e.target.tagName === 'INPUT') return; // don't intercept search typing
|
||
if (e.key === 'Escape') { closeArtistMap(); e.preventDefault(); }
|
||
else if (e.key === '=' || e.key === '+') { artMapZoom(1.3); e.preventDefault(); }
|
||
else if (e.key === '-') { artMapZoom(0.7); e.preventDefault(); }
|
||
else if (e.key === '0') { artMapFitToView(); e.preventDefault(); }
|
||
else if (e.key === 'f' || e.key === 'F') { artMapFitToView(); e.preventDefault(); }
|
||
else if (e.key === 'd' || e.key === 'D') { _artMap._perf = !_artMap._perf; _artMapRender(); e.preventDefault(); }
|
||
else if (e.key === 's' || e.key === 'S') {
|
||
const input = document.getElementById('artist-map-search');
|
||
if (input) { input.focus(); e.preventDefault(); }
|
||
}
|
||
else if (e.key === 'h' || e.key === 'H') {
|
||
// Toggle similar artists visibility
|
||
_artMap._hideSimilar = !_artMap._hideSimilar;
|
||
_artMap.dirty = true;
|
||
_artMapRender();
|
||
}
|
||
}
|
||
window.addEventListener('keydown', _artMapKeyHandler);
|
||
_artMap._keyHandler = _artMapKeyHandler;
|
||
|
||
// Right-click context menu
|
||
canvas.addEventListener('contextmenu', (e) => {
|
||
e.preventDefault();
|
||
const { nx, ny } = _artMapScreenToWorld(e, canvas);
|
||
const node = _artMapHitTest(nx, ny);
|
||
if (!node || node._isLabel) { _artMapHideContextMenu(); return; }
|
||
|
||
const menu = document.getElementById('artist-map-context') || (() => {
|
||
const m = document.createElement('div');
|
||
m.id = 'artist-map-context';
|
||
m.className = 'artmap-context-menu';
|
||
document.getElementById('artist-map-container').appendChild(m);
|
||
return m;
|
||
})();
|
||
|
||
const hasId = node.spotify_id || node.itunes_id || node.deezer_id;
|
||
const activeSource = window._yaActiveSource || 'spotify';
|
||
const bestId = node[activeSource + '_id'] || node.spotify_id || node.itunes_id || node.deezer_id || '';
|
||
const bestSource = node[activeSource + '_id'] ? activeSource : node.spotify_id ? 'spotify' : node.itunes_id ? 'itunes' : 'deezer';
|
||
|
||
menu.innerHTML = `
|
||
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); ${hasId ? `openYourArtistInfoModal_direct(${JSON.stringify(node).replace(/"/g, '"')})` : ''}">
|
||
<span>ⓘ</span> Artist Info
|
||
</div>
|
||
<a class="artmap-ctx-item" href="${bestId ? buildArtistDetailPath(bestId, bestSource) : '#'}" onclick="_artMapHideContextMenu()" ${bestId ? '' : 'aria-disabled="true" style="pointer-events:none;opacity:0.5;text-decoration:none;color:inherit;"'}>
|
||
<span>💿</span> View Discography
|
||
</a>
|
||
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); toggleYourArtistWatchlist(0,'${escapeForInlineJs(node.name)}','${escapeForInlineJs(bestId)}','${bestSource}',null)">
|
||
<span>👁</span> ${node.type === 'watchlist' ? 'On Watchlist' : 'Add to Watchlist'}
|
||
</div>
|
||
`;
|
||
menu.style.display = 'block';
|
||
menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
|
||
menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px';
|
||
|
||
// Close on next click anywhere
|
||
setTimeout(() => {
|
||
window.addEventListener('click', _artMapHideContextMenu, { once: true });
|
||
}, 10);
|
||
});
|
||
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (e.button !== 0) return; // left button only
|
||
clickStart = { x: e.clientX, y: e.clientY, time: Date.now() };
|
||
isPanning = true;
|
||
panStartX = e.clientX;
|
||
panStartY = e.clientY;
|
||
});
|
||
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (isPanning) {
|
||
_artMap.offsetX += e.clientX - panStartX;
|
||
_artMap.offsetY += e.clientY - panStartY;
|
||
panStartX = e.clientX;
|
||
panStartY = e.clientY;
|
||
_artMapRender();
|
||
} else {
|
||
const { nx, ny } = _artMapScreenToWorld(e, canvas);
|
||
const prev = _artMap.hoveredNode;
|
||
_artMap.hoveredNode = _artMapHitTest(nx, ny);
|
||
canvas.style.cursor = _artMap.hoveredNode ? 'pointer' : 'grab';
|
||
_artMapShowTooltip(e, _artMap.hoveredNode);
|
||
if (prev !== _artMap.hoveredNode) {
|
||
// Reset constellation highlight timer
|
||
clearTimeout(_artMap._constellationTimer);
|
||
if (_artMap._constellationActive) {
|
||
_artMap._constellationActive = false;
|
||
_artMapAnimateConstellation(); // fade out
|
||
}
|
||
if (_artMap.hoveredNode) {
|
||
// Snappy sustained-hover delay before the constellation lights up
|
||
// (was 800ms, which felt like nothing happened).
|
||
_artMap._constellationTimer = setTimeout(() => {
|
||
if (_artMap.hoveredNode) {
|
||
_artMap._constellationActive = true;
|
||
_artMap._constellationFade = 0;
|
||
_artMap._constellationCache = null;
|
||
_artMapAnimateConstellation();
|
||
}
|
||
}, 220);
|
||
}
|
||
_artMapRender();
|
||
}
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('mouseup', (e) => {
|
||
if (e.button !== 0) return; // left button only
|
||
const wasDrag = clickStart && (Math.abs(e.clientX - clickStart.x) > 5 || Math.abs(e.clientY - clickStart.y) > 5);
|
||
isPanning = false;
|
||
|
||
if (!wasDrag && clickStart) {
|
||
// It was a click — find the node under cursor
|
||
const { nx, ny } = _artMapScreenToWorld(e, canvas);
|
||
const node = _artMapHitTest(nx, ny);
|
||
if (node) {
|
||
_artMap._ripple = { x: node.x, y: node.y, radius: node.radius, start: performance.now() };
|
||
_artMapRender();
|
||
if (node.spotify_id || node.itunes_id || node.deezer_id) {
|
||
setTimeout(() => openYourArtistInfoModal_direct(node), 200);
|
||
}
|
||
}
|
||
}
|
||
|
||
clickStart = null;
|
||
_artMapShowTooltip(e, null);
|
||
});
|
||
|
||
canvas.addEventListener('mouseleave', () => {
|
||
_artMapShowTooltip(null, null);
|
||
clearTimeout(_artMap._constellationTimer);
|
||
if (_artMap._constellationActive) {
|
||
_artMap._constellationActive = false;
|
||
_artMapAnimateConstellation();
|
||
}
|
||
_artMap.hoveredNode = null;
|
||
_artMapRender();
|
||
});
|
||
|
||
// Touch support — single finger pan, pinch to zoom
|
||
let lastTouches = null;
|
||
canvas.addEventListener('touchstart', (e) => {
|
||
e.preventDefault();
|
||
lastTouches = [...e.touches];
|
||
}, { passive: false });
|
||
|
||
canvas.addEventListener('touchmove', (e) => {
|
||
e.preventDefault();
|
||
if (!lastTouches) return;
|
||
const touches = [...e.touches];
|
||
|
||
if (touches.length === 1 && lastTouches.length === 1) {
|
||
// Pan
|
||
_artMap.offsetX += touches[0].clientX - lastTouches[0].clientX;
|
||
_artMap.offsetY += touches[0].clientY - lastTouches[0].clientY;
|
||
_artMapRender();
|
||
} else if (touches.length === 2 && lastTouches.length === 2) {
|
||
// Pinch zoom
|
||
const prevDist = Math.hypot(lastTouches[1].clientX - lastTouches[0].clientX, lastTouches[1].clientY - lastTouches[0].clientY);
|
||
const curDist = Math.hypot(touches[1].clientX - touches[0].clientX, touches[1].clientY - touches[0].clientY);
|
||
const factor = curDist / prevDist;
|
||
const cx = (touches[0].clientX + touches[1].clientX) / 2;
|
||
const cy = (touches[0].clientY + touches[1].clientY) / 2;
|
||
const newZoom = Math.max(0.02, Math.min(3, _artMap.zoom * factor));
|
||
_artMap.offsetX = cx - (cx - _artMap.offsetX) * (newZoom / _artMap.zoom);
|
||
_artMap.offsetY = cy - (cy - _artMap.offsetY) * (newZoom / _artMap.zoom);
|
||
_artMap.zoom = newZoom;
|
||
_artMap.dirty = true;
|
||
_artMapRender();
|
||
}
|
||
lastTouches = touches;
|
||
}, { passive: false });
|
||
|
||
canvas.addEventListener('touchend', (e) => {
|
||
e.preventDefault();
|
||
// Tap to click
|
||
if (lastTouches && lastTouches.length === 1 && e.changedTouches.length === 1) {
|
||
const t = e.changedTouches[0];
|
||
const rect = canvas.getBoundingClientRect();
|
||
const wx = (t.clientX - rect.left - _artMap.offsetX) / _artMap.zoom;
|
||
const wy = (t.clientY - rect.top - _artMap.offsetY) / _artMap.zoom;
|
||
const node = _artMapHitTest(wx, wy);
|
||
if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) {
|
||
openYourArtistInfoModal_direct(node);
|
||
}
|
||
}
|
||
lastTouches = null;
|
||
}, { passive: false });
|
||
|
||
// Handle resize
|
||
window.addEventListener('resize', () => {
|
||
const container = document.getElementById('artist-map-container');
|
||
if (!container || container.style.display === 'none') return;
|
||
_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);
|
||
});
|
||
}
|
||
|
||
function _artMapScreenToWorld(e, canvas) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const sx = e.clientX - rect.left;
|
||
const sy = e.clientY - rect.top;
|
||
// Inverse of: translate(offsetX, offsetY) → scale(zoom)
|
||
return {
|
||
nx: (sx - _artMap.offsetX) / _artMap.zoom,
|
||
ny: (sy - _artMap.offsetY) / _artMap.zoom,
|
||
};
|
||
}
|
||
|
||
function _artMapHitTest(wx, wy) {
|
||
// Single O(N) pass, no per-move sort, no allocation. Watchlist nodes draw on
|
||
// top so they win ties; otherwise the first node whose circle contains the
|
||
// point wins. (A spatial grid was tried and reverted — it exploded building
|
||
// cells for large-radius genre cluster nodes. A flat scan of even thousands
|
||
// of nodes is sub-millisecond and can't lock up.)
|
||
let similarHit = null;
|
||
for (const n of _artMap.placed) {
|
||
if ((n.opacity || 0) < 0.3) continue;
|
||
const dx = wx - n.x, dy = wy - n.y;
|
||
if (dx * dx + dy * dy <= n.radius * n.radius) {
|
||
if (n.type === 'watchlist') return n;
|
||
if (!similarHit) similarHit = n;
|
||
}
|
||
}
|
||
return similarHit;
|
||
}
|
||
|
||
async function openYourArtistInfoModal_direct(node) {
|
||
// Determine best source ID — prefer active metadata source
|
||
let bestId = '', bestSource = '';
|
||
// Check what the active source is
|
||
const activeSource = window._yaActiveSource || 'spotify';
|
||
const sourceOrder = activeSource === 'spotify' ? ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id', 'musicbrainz_id']
|
||
: activeSource === 'itunes' ? ['itunes_id', 'spotify_id', 'deezer_id', 'discogs_id', 'musicbrainz_id']
|
||
: activeSource === 'deezer' ? ['deezer_id', 'spotify_id', 'itunes_id', 'discogs_id', 'musicbrainz_id']
|
||
: activeSource === 'musicbrainz' ? ['musicbrainz_id', 'spotify_id', 'itunes_id', 'deezer_id', 'discogs_id']
|
||
: ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id', 'musicbrainz_id'];
|
||
const sourceMap = { spotify_id: 'spotify', itunes_id: 'itunes', deezer_id: 'deezer', discogs_id: 'discogs', musicbrainz_id: 'musicbrainz' };
|
||
for (const key of sourceOrder) {
|
||
if (node[key]) { bestId = node[key]; bestSource = sourceMap[key]; break; }
|
||
}
|
||
|
||
// Gather ALL connected artists from map edges (both directions)
|
||
const related = [];
|
||
const relatedIds = new Set();
|
||
const nById = _artMap._nodeById || {};
|
||
_artMap.edges.forEach(e => {
|
||
if (e.source === node.id && nById[e.target] && !relatedIds.has(e.target)) {
|
||
related.push(nById[e.target]);
|
||
relatedIds.add(e.target);
|
||
}
|
||
if (e.target === node.id && nById[e.source] && !relatedIds.has(e.source)) {
|
||
related.push(nById[e.source]);
|
||
relatedIds.add(e.source);
|
||
}
|
||
});
|
||
|
||
const poolEntry = {
|
||
id: node.id,
|
||
artist_name: node.name,
|
||
active_source_id: bestId,
|
||
active_source: bestSource,
|
||
image_url: node.image_url || '',
|
||
spotify_artist_id: node.spotify_id || '',
|
||
itunes_artist_id: node.itunes_id || '',
|
||
deezer_artist_id: node.deezer_id || '',
|
||
discogs_artist_id: node.discogs_id || '',
|
||
source_services: [],
|
||
on_watchlist: node.type === 'watchlist' ? 1 : 0,
|
||
_related: related,
|
||
};
|
||
if (!window._yaArtists) window._yaArtists = {};
|
||
window._yaArtists[node.id] = poolEntry;
|
||
openYourArtistInfoModal(node.id);
|
||
}
|
||
|
||
async function loadDiscoveryShuffle() {
|
||
try {
|
||
const container = document.getElementById('personalized-discovery-shuffle');
|
||
if (!container) return;
|
||
|
||
const response = await fetch('/api/discover/personalized/discovery-shuffle?limit=50');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||
container.closest('.discover-section').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
personalizedDiscoveryShuffle = data.tracks;
|
||
renderCompactPlaylist(container, data.tracks);
|
||
container.closest('.discover-section').style.display = 'block';
|
||
|
||
} catch (error) {
|
||
console.error('Error loading discovery shuffle:', error);
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// BECAUSE YOU LISTEN TO
|
||
// ===============================
|
||
|
||
function _renderByltSection(section, idx) {
|
||
return `
|
||
<div class="discover-section bylt-section">
|
||
<div class="discover-section-header">
|
||
<div class="bylt-header">
|
||
${section.artist_image ? `<img class="bylt-artist-img" src="${section.artist_image}" alt="" onerror="this.style.display='none'">` : ''}
|
||
<div>
|
||
<div class="discover-section-subtitle">Because you listen to</div>
|
||
<h3 class="discover-section-title">${_esc(section.artist_name)}</h3>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="discover-carousel" id="bylt-carousel-${idx}"></div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function _renderByltTrackCard(t) {
|
||
return `
|
||
<div class="discover-card">
|
||
<div class="discover-card-image">
|
||
${t.image_url ? `<img src="${t.image_url}" alt="" loading="lazy" onerror="this.src='/static/placeholder-album.png'">` : '<div class="discover-card-placeholder">🎵</div>'}
|
||
</div>
|
||
<div class="discover-card-title">${_esc(t.name)}</div>
|
||
<div class="discover-card-artist">${_esc(t.artist)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
let _byltCtrl = null;
|
||
|
||
async function loadBecauseYouListenTo() {
|
||
// Ensure the BYLT container exists in the DOM. It's dynamically
|
||
// inserted after the release radar section because the markup
|
||
// doesn't ship a placeholder for it. Bail if anchor section
|
||
// isn't present.
|
||
let byltContainer = document.getElementById('discover-bylt-sections');
|
||
if (!byltContainer) {
|
||
const releaseRadar = document.getElementById('discover-release-radar');
|
||
if (!releaseRadar) return;
|
||
const parent = releaseRadar.closest('.discover-section');
|
||
if (!parent) return;
|
||
|
||
byltContainer = document.createElement('div');
|
||
byltContainer.id = 'discover-bylt-sections';
|
||
parent.parentNode.insertBefore(byltContainer, parent.nextSibling);
|
||
}
|
||
|
||
if (!_byltCtrl) {
|
||
_byltCtrl = createDiscoverSectionController({
|
||
id: 'because-you-listen-to',
|
||
contentEl: '#discover-bylt-sections',
|
||
fetchUrl: '/api/discover/because-you-listen-to',
|
||
extractItems: (data) => data.sections || [],
|
||
// No per-section empty/loading copy — when there's nothing
|
||
// to show we leave the container blank rather than render a
|
||
// placeholder, matching the original no-op behavior.
|
||
renderEmptyState: false,
|
||
loadingMessage: '',
|
||
renderItems: (items) => items.map((s, i) => _renderByltSection(s, i)).join(''),
|
||
onRendered: ({ items }) => {
|
||
// Inject track cards into each section's carousel after
|
||
// the section wrappers exist in the DOM.
|
||
items.forEach((section, idx) => {
|
||
const carousel = document.getElementById(`bylt-carousel-${idx}`);
|
||
if (!carousel) return;
|
||
carousel.innerHTML = section.tracks.map(t => _renderByltTrackCard(t)).join('');
|
||
});
|
||
},
|
||
});
|
||
}
|
||
return _byltCtrl.load();
|
||
}
|
||
|
||
// ===============================
|
||
// CACHE DISCOVERY SECTIONS
|
||
// ===============================
|
||
|
||
// Global arrays for cache discovery click handlers
|
||
let _cacheDiscoverData = {};
|
||
|
||
function _cacheDiscoverCard(item, type, sectionKey, index) {
|
||
const _esc = (s) => (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
const coverUrl = item.image_url || '/static/placeholder-album.png';
|
||
const title = item.name || '';
|
||
const subtitle = item.artist_name || '';
|
||
const meta = item.release_date ? item.release_date.substring(0, 10) : (item.label || '');
|
||
const onclick = `openCacheDiscoverAlbum('${sectionKey}',${index})`;
|
||
const libBadge = item.in_library ? '<div class="discover-card-lib-badge">In Library</div>' : '';
|
||
return `<div class="discover-card" onclick="${onclick}" style="cursor:pointer">
|
||
<div class="discover-card-image">
|
||
<img src="${_esc(coverUrl)}" alt="${_esc(title)}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
|
||
${libBadge}
|
||
</div>
|
||
<div class="discover-card-info">
|
||
<h4 class="discover-card-title">${_esc(title)}</h4>
|
||
<p class="discover-card-subtitle">${_esc(subtitle)}</p>
|
||
${meta ? `<p class="discover-card-meta">${_esc(meta)}</p>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function openCacheDiscoverAlbum(sectionKey, index) {
|
||
const items = _cacheDiscoverData[sectionKey];
|
||
if (!items || !items[index]) return;
|
||
const item = items[index];
|
||
const source = item.source || 'spotify';
|
||
const albumId = item.entity_id;
|
||
|
||
// Deep cuts / genre dive tracks — find the real album by searching the cache
|
||
if (sectionKey === 'deep_cuts' || sectionKey === 'genre_dive_tracks') {
|
||
document.getElementById('genre-deep-dive-modal')?.remove();
|
||
const albumName = item.album_name || item.name || '';
|
||
const artistName = item.artist_name || '';
|
||
const trackAlbumId = item.album_id || '';
|
||
const trackSource = item.source || source;
|
||
|
||
if (!artistName) {
|
||
showToast('No artist data available for this track', 'error');
|
||
return;
|
||
}
|
||
|
||
showLoadingOverlay(`Loading ${albumName}...`);
|
||
try {
|
||
let resolvedSource = trackSource;
|
||
let resolvedId = trackAlbumId;
|
||
let response;
|
||
|
||
// If we have an album_id, use it directly
|
||
if (trackAlbumId) {
|
||
const _params = new URLSearchParams({ name: albumName, artist: artistName });
|
||
response = await fetch(`/api/discover/album/${trackSource}/${trackAlbumId}?${_params}`);
|
||
}
|
||
|
||
// Fallback: resolve by name+artist if no album_id or direct fetch failed
|
||
if (!trackAlbumId || (response && !response.ok)) {
|
||
const searchResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(albumName)}&artist=${encodeURIComponent(artistName)}`);
|
||
if (searchResp.ok) {
|
||
const searchData = await searchResp.json();
|
||
if (searchData.success && searchData.entity_id) {
|
||
resolvedSource = searchData.source || trackSource;
|
||
resolvedId = searchData.entity_id;
|
||
const _params = new URLSearchParams({ name: albumName, artist: artistName });
|
||
response = await fetch(`/api/discover/album/${resolvedSource}/${resolvedId}?${_params}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!response || !response.ok) throw new Error('Failed to fetch album tracks');
|
||
const albumData = await response.json();
|
||
if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found');
|
||
|
||
const spotifyTracks = albumData.tracks.map(track => {
|
||
let artists = track.artists || albumData.artists || [{ name: artistName }];
|
||
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 artistContext = _buildDiscoverArtistContext(resolvedSource, artistName, item, albumData);
|
||
const albumContext = { 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 || [] };
|
||
await openDownloadMissingModalForYouTube(`discover_cache_${resolvedId}`, albumData.name, spotifyTracks, artistContext, albumContext);
|
||
hideLoadingOverlay();
|
||
} catch (error) {
|
||
console.error('Error opening deep cut album:', error);
|
||
showToast(`Failed to load album: ${error.message}`, 'error');
|
||
hideLoadingOverlay();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!albumId) {
|
||
showToast('No album ID available', 'error');
|
||
return;
|
||
}
|
||
|
||
// Close genre deep dive modal if open
|
||
document.getElementById('genre-deep-dive-modal')?.remove();
|
||
|
||
showLoadingOverlay(`Loading ${item.name || 'album'}...`);
|
||
try {
|
||
const _params = new URLSearchParams({ name: item.name || '', artist: item.artist_name || '' });
|
||
let response = await fetch(`/api/discover/album/${source}/${albumId}?${_params}`);
|
||
|
||
// If 404 (stale cache entry), try resolving via name+artist
|
||
if (response.status === 404) {
|
||
const resolveResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(item.name || '')}&artist=${encodeURIComponent(item.artist_name || '')}`);
|
||
if (resolveResp.ok) {
|
||
const resolved = await resolveResp.json();
|
||
if (resolved.success && resolved.entity_id && resolved.entity_id !== albumId) {
|
||
response = await fetch(`/api/discover/album/${resolved.source || source}/${resolved.entity_id}?${_params}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!response.ok) throw new Error('Album not available — it may have been removed from the source');
|
||
const albumData = await response.json();
|
||
if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found');
|
||
|
||
const spotifyTracks = albumData.tracks.map(track => {
|
||
let artists = track.artists || albumData.artists || [{ name: item.artist_name }];
|
||
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
|
||
return {
|
||
id: track.id,
|
||
name: track.name,
|
||
artists: 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 artistContext = _buildDiscoverArtistContext(source, item.artist_name || albumData.artists?.[0]?.name || '', item, albumData);
|
||
const albumContext = {
|
||
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 || [],
|
||
};
|
||
|
||
await openDownloadMissingModalForYouTube(
|
||
`discover_cache_${albumId}`, albumData.name, spotifyTracks, artistContext, albumContext
|
||
);
|
||
hideLoadingOverlay();
|
||
} catch (error) {
|
||
console.error('Error opening cache discover album:', error);
|
||
showToast(`Failed to load album: ${error.message}`, 'error');
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
function _insertCacheSection(id, title, subtitle, html, position) {
|
||
const container = document.getElementById('discover-bylt-sections') || document.querySelector('.discover-container');
|
||
if (!container) return;
|
||
let section = document.getElementById(id);
|
||
if (!section) {
|
||
section = document.createElement('div');
|
||
section.id = id;
|
||
section.className = 'discover-section';
|
||
if (position === 'top') {
|
||
// Insert after the hero section (first child), not before it
|
||
const hero = container.querySelector('.discover-hero');
|
||
if (hero && hero.nextSibling) {
|
||
container.insertBefore(section, hero.nextSibling);
|
||
} else {
|
||
container.prepend(section);
|
||
}
|
||
} else {
|
||
container.appendChild(section);
|
||
}
|
||
}
|
||
section.innerHTML = `
|
||
<div class="discover-section-header">
|
||
<div>
|
||
<div class="discover-section-subtitle">${subtitle}</div>
|
||
<h3 class="discover-section-title">${title}</h3>
|
||
</div>
|
||
</div>
|
||
<div class="discover-carousel">${html}</div>
|
||
`;
|
||
}
|
||
|
||
async function loadCacheUndiscoveredAlbums() {
|
||
try {
|
||
const resp = await fetch('/api/discover/undiscovered-albums');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!data.success || !data.albums || !data.albums.length) return;
|
||
_cacheDiscoverData['undiscovered'] = data.albums;
|
||
_insertCacheSection('cache-undiscovered',
|
||
'Undiscovered Albums', 'From artists you love',
|
||
data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'undiscovered', i)).join(''));
|
||
} catch (e) { console.debug('Cache undiscovered albums:', e); }
|
||
}
|
||
|
||
async function loadCacheGenreNewReleases() {
|
||
try {
|
||
const resp = await fetch('/api/discover/genre-new-releases');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!data.success || !data.albums || !data.albums.length) return;
|
||
_cacheDiscoverData['genre_releases'] = data.albums;
|
||
_insertCacheSection('cache-genre-releases',
|
||
'New In Your Genres', 'Released in the last 90 days',
|
||
data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'genre_releases', i)).join(''));
|
||
} catch (e) { console.debug('Cache genre new releases:', e); }
|
||
}
|
||
|
||
async function loadCacheLabelExplorer() {
|
||
try {
|
||
const resp = await fetch('/api/discover/label-explorer');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!data.success || !data.albums || !data.albums.length) return;
|
||
_cacheDiscoverData['label_explorer'] = data.albums;
|
||
_insertCacheSection('cache-label-explorer',
|
||
'From Your Labels', 'Popular on labels in your library',
|
||
data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'label_explorer', i)).join(''));
|
||
} catch (e) { console.debug('Cache label explorer:', e); }
|
||
}
|
||
|
||
async function loadCacheDeepCuts() {
|
||
try {
|
||
const resp = await fetch('/api/discover/deep-cuts');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!data.success || !data.tracks || !data.tracks.length) return;
|
||
_cacheDiscoverData['deep_cuts'] = data.tracks;
|
||
_insertCacheSection('cache-deep-cuts',
|
||
'Deep Cuts', 'Hidden tracks from artists you know',
|
||
data.tracks.map((t, i) => _cacheDiscoverCard(t, 'track', 'deep_cuts', i)).join(''));
|
||
} catch (e) { console.debug('Cache deep cuts:', e); }
|
||
}
|
||
|
||
async function loadCacheGenreExplorer() {
|
||
try {
|
||
const resp = await fetch('/api/discover/genre-explorer');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!data.success || !data.genres || !data.genres.length) return;
|
||
const _esc = (s) => (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, ''');
|
||
const html = `<div class="genre-explorer-grid">${data.genres.map(g => `
|
||
<div class="genre-explorer-pill ${g.explored ? 'explored' : 'unexplored'}" onclick="openGenreDeepDive('${_esc(g.genre)}')" style="cursor:pointer">
|
||
<span class="genre-pill-name">${_esc(g.genre)}</span>
|
||
<span class="genre-pill-count">${g.artist_count} artist${g.artist_count !== 1 ? 's' : ''}</span>
|
||
${!g.explored ? '<span class="genre-pill-badge">New</span>' : ''}
|
||
</div>
|
||
`).join('')}</div>`;
|
||
_insertCacheSection('cache-genre-explorer',
|
||
'Genre Explorer', 'Tap a genre to explore', html, 'top');
|
||
} catch (e) { console.debug('Cache genre explorer:', e); }
|
||
}
|
||
|
||
async function openGenreDeepDive(genre) {
|
||
document.getElementById('genre-deep-dive-modal')?.remove();
|
||
|
||
const _esc = (s) => (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||
const _fmtNum = (n) => {
|
||
if (!n) return '';
|
||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||
if (n >= 1000) return (n / 1000).toFixed(0) + 'K';
|
||
return n.toString();
|
||
};
|
||
const _fmtDur = (ms) => {
|
||
if (!ms) return '';
|
||
const m = Math.floor(ms / 60000);
|
||
const s = Math.floor((ms % 60000) / 1000);
|
||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||
};
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'genre-deep-dive-modal';
|
||
overlay.className = 'genre-dive-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="genre-dive-modal">
|
||
<div class="genre-dive-header">
|
||
<div>
|
||
<div class="genre-dive-subtitle">Genre Deep Dive</div>
|
||
<h2 class="genre-dive-title">${_esc(genre)}</h2>
|
||
</div>
|
||
<button class="genre-dive-close" onclick="document.getElementById('genre-deep-dive-modal').remove()">×</button>
|
||
</div>
|
||
<div class="genre-dive-body" id="genre-dive-body">
|
||
<div class="genre-dive-loading"><div class="genre-dive-spinner"></div>Exploring ${_esc(genre)}...</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 += `<div class="genre-dive-related">
|
||
<div class="genre-dive-related-label">Related Genres</div>
|
||
${data.related_genres.map(rg => `
|
||
<button class="genre-dive-related-pill" onclick="document.getElementById('genre-deep-dive-modal').remove();openGenreDeepDive('${_esc(rg.genre)}')">${_esc(rg.genre)}</button>
|
||
`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
// 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 += `<div class="genre-dive-section">
|
||
<h3 class="genre-dive-section-title"><span class="genre-dive-icon">🎤</span> Artists in ${_esc(genre)}</h3>
|
||
<div class="genre-dive-artists">
|
||
${data.artists.map(a => {
|
||
// Always open on Artists page with discography — pass source for correct routing
|
||
const imgUrl = _esc(a.image_url || '');
|
||
const artSource = _esc(a.source || '');
|
||
const detailHref = a.entity_id ? buildArtistDetailPath(a.entity_id, a.source || null) : '#';
|
||
const srcClass = (a.source || '').toLowerCase();
|
||
return `<a class="genre-dive-artist" href="${detailHref}" onclick="document.getElementById('genre-deep-dive-modal').remove()" style="text-decoration:none;color:inherit;">
|
||
<div class="genre-dive-artist-img" style="${a.image_url ? `background-image:url('${_esc(a.image_url)}')` : ''}">
|
||
${!a.image_url ? '<span>🎤</span>' : ''}
|
||
</div>
|
||
<span class="genre-dive-src-dot genre-dive-src-${srcClass}"></span>
|
||
<div class="genre-dive-artist-name">${_esc(a.name)}</div>
|
||
${a.followers ? `<div class="genre-dive-artist-meta">${_fmtNum(a.followers)} followers</div>` : ''}
|
||
${a.library_id ? '<div class="genre-dive-artist-badge">In Library</div>' : ''}
|
||
</a>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Tracks section — clickable, opens album download
|
||
if (data.tracks && data.tracks.length) {
|
||
_cacheDiscoverData['genre_dive_tracks'] = data.tracks;
|
||
html += `<div class="genre-dive-section">
|
||
<h3 class="genre-dive-section-title"><span class="genre-dive-icon">🎵</span> Popular Tracks</h3>
|
||
<div class="genre-dive-tracks">
|
||
${data.tracks.map((t, i) => {
|
||
const tSrcClass = (t.source || '').toLowerCase();
|
||
return `
|
||
<div class="genre-dive-track" onclick="document.getElementById('genre-deep-dive-modal').remove();openCacheDiscoverAlbum('genre_dive_tracks',${i})">
|
||
<div class="genre-dive-track-num">${i + 1}</div>
|
||
<div class="genre-dive-track-img" style="${t.image_url ? `background-image:url('${_esc(t.image_url)}')` : ''}">
|
||
${!t.image_url ? '🎵' : ''}
|
||
</div>
|
||
<div class="genre-dive-track-info">
|
||
<div class="genre-dive-track-name">${_esc(t.name)}</div>
|
||
<div class="genre-dive-track-artist">${_esc(t.artist_name)}${t.album_name ? ' · ' + _esc(t.album_name) : ''}</div>
|
||
</div>
|
||
<span class="genre-dive-src-dot genre-dive-src-${tSrcClass}" style="flex-shrink:0"></span>
|
||
<div class="genre-dive-track-duration">${_fmtDur(t.duration_ms)}</div>
|
||
</div>
|
||
`}).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Albums section
|
||
if (data.albums && data.albums.length) {
|
||
_cacheDiscoverData['genre_dive_albums'] = data.albums;
|
||
html += `<div class="genre-dive-section">
|
||
<h3 class="genre-dive-section-title"><span class="genre-dive-icon">💿</span> Albums</h3>
|
||
<div class="discover-carousel">${data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'genre_dive_albums', i)).join('')}</div>
|
||
</div>`;
|
||
}
|
||
|
||
if (!html) {
|
||
html = '<div class="genre-dive-empty"><div class="genre-dive-empty-icon">🔍</div><p>No cached data found for this genre yet</p><p class="genre-dive-empty-hint">Search for artists in this genre to build up the cache</p></div>';
|
||
}
|
||
|
||
body.innerHTML = html;
|
||
} catch (e) {
|
||
const body = document.getElementById('genre-dive-body');
|
||
if (body) body.innerHTML = '<div style="color:rgba(255,100,100,0.6);text-align:center;padding:40px;">Failed to load genre data</div>';
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// BUILD A PLAYLIST FEATURE
|
||
// ===============================
|
||
|
||
let buildPlaylistSearchTimeout = null;
|
||
|
||
async function searchBuildPlaylistArtists() {
|
||
const searchInput = document.getElementById('build-playlist-search');
|
||
const resultsContainer = document.getElementById('build-playlist-search-results');
|
||
const spinner = document.getElementById('bp-search-spinner');
|
||
const query = searchInput.value.trim();
|
||
|
||
if (!query) {
|
||
resultsContainer.innerHTML = '';
|
||
resultsContainer.style.display = 'none';
|
||
if (spinner) spinner.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// Debounce search
|
||
clearTimeout(buildPlaylistSearchTimeout);
|
||
buildPlaylistSearchTimeout = setTimeout(async () => {
|
||
if (spinner) spinner.style.display = 'flex';
|
||
try {
|
||
const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`);
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
showToast(data.error || 'Search failed', 'error');
|
||
return;
|
||
}
|
||
if (!data.success || !data.artists || data.artists.length === 0) {
|
||
resultsContainer.innerHTML = '<div class="build-playlist-no-results">No artists found for "' + query.replace(/</g, '<') + '"</div>';
|
||
resultsContainer.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Filter out already-selected artists
|
||
const selectedIds = new Set(buildPlaylistSelectedArtists.map(a => a.id));
|
||
const filtered = data.artists.filter(a => !selectedIds.has(a.id));
|
||
|
||
if (filtered.length === 0) {
|
||
resultsContainer.innerHTML = '<div class="build-playlist-no-results">All results already selected</div>';
|
||
resultsContainer.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// Render search results
|
||
let html = '';
|
||
filtered.forEach(artist => {
|
||
const imageUrl = artist.image_url || '/static/placeholder-album.png';
|
||
const escapedName = artist.name.replace(/'/g, "\\'").replace(/"/g, '"');
|
||
html += `
|
||
<div class="build-playlist-search-result" onclick="addBuildPlaylistArtist('${artist.id}', '${escapedName}', '${imageUrl}')">
|
||
<img src="${imageUrl}" alt="${artist.name}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
|
||
<span class="bp-result-name">${artist.name}</span>
|
||
<span class="bp-result-add">+ Add</span>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
resultsContainer.innerHTML = html;
|
||
resultsContainer.style.display = 'block';
|
||
|
||
} catch (error) {
|
||
console.error('Error searching artists:', error);
|
||
} finally {
|
||
if (spinner) spinner.style.display = 'none';
|
||
}
|
||
}, 400);
|
||
}
|
||
|
||
function addBuildPlaylistArtist(artistId, artistName, imageUrl) {
|
||
if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) {
|
||
showToast('Artist already selected', 'warning');
|
||
return;
|
||
}
|
||
if (buildPlaylistSelectedArtists.length >= 5) {
|
||
showToast('Maximum 5 seed artists', 'warning');
|
||
return;
|
||
}
|
||
|
||
buildPlaylistSelectedArtists.push({
|
||
id: artistId,
|
||
name: artistName,
|
||
image_url: imageUrl
|
||
});
|
||
|
||
renderBuildPlaylistSelectedArtists();
|
||
|
||
// Clear search
|
||
document.getElementById('build-playlist-search').value = '';
|
||
document.getElementById('build-playlist-search-results').innerHTML = '';
|
||
document.getElementById('build-playlist-search-results').style.display = 'none';
|
||
}
|
||
|
||
function removeBuildPlaylistArtist(artistId) {
|
||
buildPlaylistSelectedArtists = buildPlaylistSelectedArtists.filter(a => a.id !== artistId);
|
||
renderBuildPlaylistSelectedArtists();
|
||
}
|
||
|
||
function renderBuildPlaylistSelectedArtists() {
|
||
const container = document.getElementById('build-playlist-selected-artists');
|
||
const generateBtn = document.getElementById('build-playlist-generate-btn');
|
||
const counter = document.getElementById('bp-selected-counter');
|
||
const count = buildPlaylistSelectedArtists.length;
|
||
|
||
if (counter) counter.textContent = `${count} / 5`;
|
||
|
||
if (count === 0) {
|
||
container.innerHTML = `
|
||
<div class="build-playlist-no-selection">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; opacity: 0.4; margin-bottom: 8px;"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||
<span>Search above to add seed artists</span>
|
||
</div>`;
|
||
generateBtn.disabled = true;
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
buildPlaylistSelectedArtists.forEach(artist => {
|
||
const escapedId = artist.id.replace(/'/g, "\\'");
|
||
html += `
|
||
<div class="build-playlist-selected-artist">
|
||
<img src="${artist.image_url || '/static/placeholder-album.png'}" alt="${artist.name}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
|
||
<span>${artist.name}</span>
|
||
<button onclick="removeBuildPlaylistArtist('${escapedId}')" class="build-playlist-remove-artist" title="Remove">×</button>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
generateBtn.disabled = false;
|
||
}
|
||
|
||
let buildPlaylistTracks = [];
|
||
|
||
async function generateBuildPlaylist() {
|
||
if (buildPlaylistSelectedArtists.length === 0) {
|
||
showToast('Please select at least 1 artist', 'warning');
|
||
return;
|
||
}
|
||
|
||
const generateBtn = document.getElementById('build-playlist-generate-btn');
|
||
const resultsContainer = document.getElementById('build-playlist-results');
|
||
const resultsWrapper = document.getElementById('build-playlist-results-wrapper');
|
||
const loadingIndicator = document.getElementById('build-playlist-loading');
|
||
const metadataDisplay = document.getElementById('build-playlist-metadata-display');
|
||
const titleEl = document.getElementById('build-playlist-results-title');
|
||
const subtitleEl = document.getElementById('build-playlist-results-subtitle');
|
||
|
||
// Show loading, hide search area
|
||
generateBtn.disabled = true;
|
||
loadingIndicator.style.display = 'flex';
|
||
resultsWrapper.style.display = 'none';
|
||
resultsContainer.innerHTML = '';
|
||
|
||
try {
|
||
const seedIds = buildPlaylistSelectedArtists.map(a => a.id);
|
||
const response = await fetch('/api/discover/build-playlist/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
seed_artist_ids: seedIds,
|
||
playlist_size: 50
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (!response.ok || !data.success) {
|
||
throw new Error(data.error || 'Failed to generate playlist');
|
||
}
|
||
if (!data.playlist || !data.playlist.tracks || data.playlist.tracks.length === 0) {
|
||
throw new Error(data.playlist?.error || 'No tracks found. Try different seed artists.');
|
||
}
|
||
|
||
// Store tracks globally
|
||
buildPlaylistTracks = data.playlist.tracks;
|
||
|
||
// Update title and subtitle
|
||
const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', ');
|
||
titleEl.textContent = 'Custom Playlist';
|
||
subtitleEl.textContent = `Based on: ${artistNames}`;
|
||
|
||
// Render metadata
|
||
const metadata = data.playlist.metadata;
|
||
metadataDisplay.innerHTML = `
|
||
<div class="build-playlist-metadata">
|
||
<div class="bp-meta-stat">
|
||
<span class="bp-meta-value">${metadata.total_tracks}</span>
|
||
<span class="bp-meta-label">Tracks</span>
|
||
</div>
|
||
<div class="bp-meta-stat">
|
||
<span class="bp-meta-value">${metadata.similar_artists_count}</span>
|
||
<span class="bp-meta-label">Similar Artists</span>
|
||
</div>
|
||
<div class="bp-meta-stat">
|
||
<span class="bp-meta-value">${metadata.albums_count}</span>
|
||
<span class="bp-meta-label">Albums Sampled</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Render playlist
|
||
renderCompactPlaylist(resultsContainer, data.playlist.tracks);
|
||
|
||
// Show results wrapper
|
||
resultsWrapper.style.display = 'block';
|
||
|
||
} catch (error) {
|
||
console.error('Error generating playlist:', error);
|
||
resultsWrapper.style.display = 'none';
|
||
showToast(error.message || 'Failed to generate playlist', 'error');
|
||
} finally {
|
||
loadingIndicator.style.display = 'none';
|
||
generateBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function openDownloadModalForBuildPlaylist() {
|
||
if (!buildPlaylistTracks || buildPlaylistTracks.length === 0) {
|
||
showToast('No playlist tracks available', 'warning');
|
||
return;
|
||
}
|
||
|
||
const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', ');
|
||
const playlistName = `Custom Playlist - ${artistNames}`;
|
||
const virtualPlaylistId = 'build_playlist_custom';
|
||
|
||
// Open download modal
|
||
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, buildPlaylistTracks);
|
||
}
|
||
|
||
function openDailyMix(mixIndex) {
|
||
const mix = personalizedDailyMixes[mixIndex];
|
||
if (!mix || !mix.tracks) return;
|
||
|
||
// TODO: Open modal or dedicated view for Daily Mix
|
||
console.log('Opening Daily Mix:', mix.name);
|
||
}
|
||
|
||
// ===============================
|
||
// DISCOVER PLAYLIST ACTIONS
|
||
// ===============================
|
||
|
||
async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName) {
|
||
console.log(`📥 Opening Download Missing Tracks modal for ${playlistName}`);
|
||
|
||
try {
|
||
// Get tracks based on playlist type
|
||
let tracks = [];
|
||
if (playlistType === 'release_radar') {
|
||
tracks = discoverReleaseRadarTracks;
|
||
} else if (playlistType === 'discovery_weekly') {
|
||
tracks = discoverWeeklyTracks;
|
||
} else if (playlistType === 'seasonal_playlist') {
|
||
tracks = discoverSeasonalTracks;
|
||
} else if (playlistType === 'popular_picks') {
|
||
tracks = personalizedPopularPicks;
|
||
} else if (playlistType === 'hidden_gems') {
|
||
tracks = personalizedHiddenGems;
|
||
} else if (playlistType === 'discovery_shuffle') {
|
||
tracks = personalizedDiscoveryShuffle;
|
||
} else if (playlistType === 'build_playlist') {
|
||
tracks = buildPlaylistTracks;
|
||
}
|
||
|
||
if (!tracks || tracks.length === 0) {
|
||
showToast(`No tracks available for ${playlistName}`, 'warning');
|
||
return;
|
||
}
|
||
|
||
// Convert discover tracks to format expected by download modal
|
||
const spotifyTracks = tracks.map(track => {
|
||
let spotifyTrack;
|
||
|
||
// Use track_data_json if available, otherwise construct from track data
|
||
if (track.track_data_json) {
|
||
spotifyTrack = track.track_data_json;
|
||
} else {
|
||
// Fallback: construct track object from available data
|
||
spotifyTrack = {
|
||
id: track.spotify_track_id,
|
||
name: track.track_name,
|
||
artists: [{ name: track.artist_name }],
|
||
album: {
|
||
name: track.album_name,
|
||
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
|
||
},
|
||
duration_ms: track.duration_ms || 0
|
||
};
|
||
}
|
||
|
||
// Normalize artists to array of strings for modal compatibility
|
||
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
|
||
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
|
||
}
|
||
|
||
return spotifyTrack;
|
||
});
|
||
|
||
// Create virtual playlist ID
|
||
const virtualPlaylistId = `discover_${playlistType}`;
|
||
|
||
// Use existing modal system (same as YouTube/Tidal playlists)
|
||
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
||
|
||
} catch (error) {
|
||
console.error('Error opening download modal for discover playlist:', error);
|
||
showToast(`Failed to open download modal: ${error.message}`, 'error');
|
||
hideLoadingOverlay(); // Ensure overlay is hidden on error
|
||
}
|
||
}
|
||
|
||
function updateDiscoverDownloadButton(playlistType, state) {
|
||
/**
|
||
* Update the download button appearance based on download state
|
||
* @param {string} playlistType - 'release_radar' or 'discovery_weekly'
|
||
* @param {string} state - 'idle', 'downloading', or 'complete'
|
||
*/
|
||
const buttonId = `${playlistType}-download-btn`;
|
||
const button = document.getElementById(buttonId);
|
||
|
||
if (!button) return;
|
||
|
||
const icon = button.querySelector('.button-icon');
|
||
const text = button.querySelector('.button-text');
|
||
|
||
if (state === 'downloading') {
|
||
if (icon) icon.textContent = '⏳';
|
||
if (text) text.textContent = 'View Progress';
|
||
button.title = 'View download progress';
|
||
} else {
|
||
if (icon) icon.textContent = '↓';
|
||
if (text) text.textContent = 'Download';
|
||
button.title = 'Download missing tracks';
|
||
}
|
||
}
|
||
|
||
function checkForActiveDiscoverDownloads() {
|
||
/**
|
||
* Check for active download processes and update button states
|
||
* Only runs if discover page is actually loaded in the DOM
|
||
*/
|
||
// Check if discover page is loaded by looking for a discover-specific element
|
||
const discoverPage = document.getElementById('release-radar-download-btn') ||
|
||
document.getElementById('discovery-weekly-download-btn');
|
||
|
||
if (!discoverPage) return;
|
||
|
||
const discoverPlaylists = [
|
||
{ id: 'discover_release_radar', type: 'release_radar' },
|
||
{ id: 'discover_discovery_weekly', type: 'discovery_weekly' }
|
||
];
|
||
|
||
discoverPlaylists.forEach(({ id, type }) => {
|
||
if (activeDownloadProcesses[id]) {
|
||
const process = activeDownloadProcesses[id];
|
||
if (process.status === 'running' || process.status === 'idle') {
|
||
updateDiscoverDownloadButton(type, 'downloading');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function startDiscoverPlaylistSync(playlistType, playlistName) {
|
||
console.log(`🔄 Starting sync for ${playlistName}`);
|
||
|
||
// Get tracks based on playlist type
|
||
let tracks = [];
|
||
if (playlistType === 'release_radar') {
|
||
tracks = discoverReleaseRadarTracks;
|
||
} else if (playlistType === 'discovery_weekly') {
|
||
tracks = discoverWeeklyTracks;
|
||
} else if (playlistType === 'seasonal_playlist') {
|
||
tracks = discoverSeasonalTracks;
|
||
} else if (playlistType === 'popular_picks') {
|
||
tracks = personalizedPopularPicks;
|
||
} else if (playlistType === 'hidden_gems') {
|
||
tracks = personalizedHiddenGems;
|
||
} else if (playlistType === 'discovery_shuffle') {
|
||
tracks = personalizedDiscoveryShuffle;
|
||
} else if (playlistType === 'build_playlist') {
|
||
tracks = buildPlaylistTracks;
|
||
}
|
||
|
||
if (!tracks || tracks.length === 0) {
|
||
showToast(`No tracks available for ${playlistName}`, 'warning');
|
||
return;
|
||
}
|
||
|
||
// Convert to format expected by sync API
|
||
const spotifyTracks = tracks.map(track => {
|
||
let spotifyTrack;
|
||
|
||
// Use track_data_json if available
|
||
if (track.track_data_json) {
|
||
spotifyTrack = track.track_data_json;
|
||
} else {
|
||
// Fallback: construct track object
|
||
spotifyTrack = {
|
||
id: track.spotify_track_id,
|
||
name: track.track_name,
|
||
artists: [{ name: track.artist_name }],
|
||
album: {
|
||
name: track.album_name,
|
||
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
|
||
},
|
||
duration_ms: track.duration_ms || 0
|
||
};
|
||
}
|
||
|
||
// Normalize artists to array of strings for sync compatibility
|
||
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
|
||
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
|
||
}
|
||
|
||
return spotifyTrack;
|
||
});
|
||
|
||
// Create virtual playlist ID
|
||
const virtualPlaylistId = `discover_${playlistType}`;
|
||
|
||
// Store in cache for sync function
|
||
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
|
||
|
||
// Create virtual playlist object
|
||
const virtualPlaylist = {
|
||
id: virtualPlaylistId,
|
||
name: playlistName,
|
||
track_count: spotifyTracks.length
|
||
};
|
||
|
||
// Add to spotify playlists array if not already there
|
||
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
|
||
spotifyPlaylists.push(virtualPlaylist);
|
||
}
|
||
|
||
// Show sync status display (convert underscores to hyphens for ID)
|
||
const statusId = playlistType.replace(/_/g, '-') + '-sync-status';
|
||
const statusDisplay = document.getElementById(statusId);
|
||
if (statusDisplay) {
|
||
statusDisplay.style.display = 'block';
|
||
}
|
||
|
||
// Disable sync button to prevent duplicate syncs (convert underscores to hyphens for ID)
|
||
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
|
||
const syncButton = document.getElementById(buttonId);
|
||
if (syncButton) {
|
||
syncButton.disabled = true;
|
||
syncButton.style.opacity = '0.5';
|
||
syncButton.style.cursor = 'not-allowed';
|
||
}
|
||
|
||
// Start sync using existing function
|
||
await startPlaylistSync(virtualPlaylistId);
|
||
|
||
// Extract image URL from first track for download bar bubble
|
||
let imageUrl = null;
|
||
if (spotifyTracks && spotifyTracks.length > 0) {
|
||
const firstTrack = spotifyTracks[0];
|
||
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
|
||
imageUrl = firstTrack.album.images[0].url;
|
||
}
|
||
}
|
||
|
||
// Add to discover download bar
|
||
addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl);
|
||
|
||
// Start polling for progress updates
|
||
startDiscoverSyncPolling(playlistType, virtualPlaylistId);
|
||
}
|
||
|
||
// Track active discover sync pollers
|
||
const discoverSyncPollers = {};
|
||
|
||
function startDiscoverSyncPolling(playlistType, virtualPlaylistId) {
|
||
// Stop any existing poller for this playlist type
|
||
if (discoverSyncPollers[playlistType]) {
|
||
clearInterval(discoverSyncPollers[playlistType]);
|
||
}
|
||
|
||
console.log(`🔄 Starting sync polling for ${playlistType} (${virtualPlaylistId})`);
|
||
|
||
// Phase 5: Subscribe via WebSocket
|
||
if (socketConnected) {
|
||
socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] });
|
||
_syncProgressCallbacks[virtualPlaylistId] = (data) => {
|
||
const prefix = playlistType.replace(/_/g, '-');
|
||
const progress = data.progress || {};
|
||
const total = progress.total_tracks || 0;
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const processed = matched + failed;
|
||
const pending = total - processed;
|
||
const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
const el = (id) => document.getElementById(id);
|
||
if (el(`${prefix}-sync-completed`)) el(`${prefix}-sync-completed`).textContent = matched;
|
||
if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = pending;
|
||
if (el(`${prefix}-sync-failed`)) el(`${prefix}-sync-failed`).textContent = failed;
|
||
if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = pct;
|
||
if (data.status === 'finished') {
|
||
if (discoverSyncPollers[playlistType]) { clearInterval(discoverSyncPollers[playlistType]); delete discoverSyncPollers[playlistType]; }
|
||
socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] });
|
||
delete _syncProgressCallbacks[virtualPlaylistId];
|
||
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
|
||
const syncButton = el(buttonId);
|
||
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
|
||
const playlistNames = {
|
||
'release_radar': 'Fresh Tape', 'discovery_weekly': 'The Archives',
|
||
'seasonal_playlist': 'Seasonal Mix', 'popular_picks': 'Popular Picks',
|
||
'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle',
|
||
'build_playlist': 'Custom Playlist'
|
||
};
|
||
showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success');
|
||
setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Poll every 500ms for progress updates
|
||
discoverSyncPollers[playlistType] = setInterval(async () => {
|
||
// Always poll — no dedicated WebSocket events for discovery progress
|
||
try {
|
||
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
|
||
if (!response.ok) {
|
||
console.log(`⚠️ Sync status response not OK: ${response.status}`);
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log(`📊 Sync status for ${playlistType}:`, data);
|
||
|
||
// Update UI with progress (data structure: {status: ..., progress: {...}})
|
||
// Convert underscores to hyphens for HTML IDs
|
||
const prefix = playlistType.replace(/_/g, '-');
|
||
const progress = data.progress || {};
|
||
|
||
const completedEl = document.getElementById(`${prefix}-sync-completed`);
|
||
const pendingEl = document.getElementById(`${prefix}-sync-pending`);
|
||
const failedEl = document.getElementById(`${prefix}-sync-failed`);
|
||
const percentageEl = document.getElementById(`${prefix}-sync-percentage`);
|
||
|
||
const total = progress.total_tracks || 0;
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const processed = matched + failed;
|
||
const pending = total - processed;
|
||
const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
|
||
if (completedEl) completedEl.textContent = matched;
|
||
if (pendingEl) pendingEl.textContent = pending;
|
||
if (failedEl) failedEl.textContent = failed;
|
||
if (percentageEl) percentageEl.textContent = completionPercentage;
|
||
|
||
// If complete, stop polling and hide status after delay
|
||
if (data.status === 'finished') {
|
||
console.log(`✅ Sync complete for ${playlistType}`);
|
||
clearInterval(discoverSyncPollers[playlistType]);
|
||
delete discoverSyncPollers[playlistType];
|
||
|
||
// Re-enable sync button
|
||
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
|
||
const syncButton = document.getElementById(buttonId);
|
||
if (syncButton) {
|
||
syncButton.disabled = false;
|
||
syncButton.style.opacity = '1';
|
||
syncButton.style.cursor = 'pointer';
|
||
}
|
||
|
||
// Show completion toast with playlist name
|
||
const playlistNames = {
|
||
'release_radar': 'Fresh Tape',
|
||
'discovery_weekly': 'The Archives',
|
||
'seasonal_playlist': 'Seasonal Mix',
|
||
'popular_picks': 'Popular Picks',
|
||
'hidden_gems': 'Hidden Gems',
|
||
'discovery_shuffle': 'Discovery Shuffle',
|
||
'build_playlist': 'Custom Playlist'
|
||
};
|
||
const displayName = playlistNames[playlistType] || playlistType;
|
||
showToast(`${displayName} sync complete!`, 'success');
|
||
|
||
// Hide status display after 3 seconds
|
||
setTimeout(() => {
|
||
const statusDisplay = document.getElementById(`${prefix}-sync-status`);
|
||
if (statusDisplay) {
|
||
statusDisplay.style.display = 'none';
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error polling sync status for ${playlistType}:`, error);
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
async function openDownloadModalForRecentAlbum(albumIndex) {
|
||
const album = discoverRecentAlbums[albumIndex];
|
||
if (!album) {
|
||
showToast('Album data not found', 'error');
|
||
return;
|
||
}
|
||
|
||
console.log(`📥 Opening Download Missing Tracks modal for album: ${album.album_name}`);
|
||
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
|
||
|
||
try {
|
||
// Determine source and album ID - use source-agnostic endpoint
|
||
const source = album.source || (album.album_spotify_id ? 'spotify' : album.album_deezer_id ? 'deezer' : 'itunes');
|
||
const albumId = source === 'spotify' ? album.album_spotify_id : source === 'deezer' ? album.album_deezer_id : album.album_itunes_id;
|
||
|
||
if (!albumId) {
|
||
throw new Error(`No ${source} album ID available`);
|
||
}
|
||
|
||
// Fetch album tracks from appropriate source (pass name/artist for Hydrabase support)
|
||
const _dap2 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
|
||
const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap2}`);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch album tracks');
|
||
}
|
||
|
||
const albumData = await response.json();
|
||
if (!albumData.tracks || albumData.tracks.length === 0) {
|
||
throw new Error('No tracks found in album');
|
||
}
|
||
|
||
// Convert to expected format - CRITICAL FIX: Use fresh albumData from Spotify, not cached album
|
||
const spotifyTracks = albumData.tracks.map(track => {
|
||
// Normalize artists to array of strings
|
||
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: artists,
|
||
album: {
|
||
id: albumData.id, // ✅ Album ID for proper tracking
|
||
name: albumData.name, // ✅ Use fresh data, not cached
|
||
album_type: albumData.album_type || 'album', // ✅ Critical: Album type for classification
|
||
total_tracks: albumData.total_tracks || 0, // ✅ Total tracks for context
|
||
release_date: albumData.release_date || '', // ✅ Release date
|
||
images: albumData.images || [] // ✅ Use Spotify images
|
||
},
|
||
duration_ms: track.duration_ms || 0,
|
||
track_number: track.track_number || 0
|
||
};
|
||
});
|
||
|
||
// Create virtual playlist ID using the appropriate album ID
|
||
const virtualPlaylistId = `discover_album_${albumId}`;
|
||
|
||
// CRITICAL FIX: Pass proper artist/album context for modal display
|
||
const artistContext = _buildDiscoverArtistContext(source, album.artist_name, album, albumData);
|
||
|
||
const albumContext = {
|
||
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 || []
|
||
};
|
||
|
||
// Open download modal with artist/album context
|
||
await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext);
|
||
|
||
hideLoadingOverlay();
|
||
|
||
} catch (error) {
|
||
console.error('Error opening album download modal:', error);
|
||
showToast(`Failed to load album: ${error.message}`, 'error');
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// DISCOVER DOWNLOAD BAR
|
||
// ===============================
|
||
|
||
// Track discover page downloads
|
||
let discoverDownloads = {}; // playlistId -> { name, type, status, virtualPlaylistId, startTime }
|
||
|
||
/**
|
||
* Add a download to the discover download bar
|
||
*/
|
||
function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl = null) {
|
||
console.log(`📥 [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`);
|
||
|
||
// Always register the download in state (needed for dashboard even when not on discover page)
|
||
discoverDownloads[playlistId] = {
|
||
name: playlistName,
|
||
type: playlistType,
|
||
status: 'in_progress',
|
||
virtualPlaylistId: playlistId,
|
||
imageUrl: imageUrl,
|
||
startTime: new Date()
|
||
};
|
||
|
||
console.log(`📊 [DOWNLOAD SIDEBAR] Active downloads:`, Object.keys(discoverDownloads));
|
||
|
||
// Update discover page sidebar if it exists (user is on discover page)
|
||
const downloadSidebar = document.getElementById('discover-download-sidebar');
|
||
if (downloadSidebar) {
|
||
updateDiscoverDownloadBar(); // Also saves snapshot internally
|
||
} else {
|
||
console.log('ℹ️ [DOWNLOAD SIDEBAR] Sidebar not present - skipping sidebar UI update');
|
||
saveDiscoverDownloadSnapshot(); // Persist state even when sidebar is absent
|
||
}
|
||
|
||
updateDashboardDownloads();
|
||
monitorDiscoverDownload(playlistId);
|
||
}
|
||
|
||
/**
|
||
* Monitor a discover download for completion
|
||
*/
|
||
function monitorDiscoverDownload(playlistId) {
|
||
let notFoundCount = 0;
|
||
const maxNotFoundAttempts = 5; // Give sync 10 seconds to start (5 checks * 2 seconds)
|
||
|
||
// Phase 5: Subscribe via WebSocket for sync status updates
|
||
if (socketConnected) {
|
||
socket.emit('sync:subscribe', { playlist_ids: [playlistId] });
|
||
_syncProgressCallbacks[playlistId] = (data) => {
|
||
if (!discoverDownloads[playlistId]) return;
|
||
if (data.status === 'complete' || data.status === 'finished') {
|
||
discoverDownloads[playlistId].status = 'completed';
|
||
updateDiscoverDownloadBar();
|
||
updateDashboardDownloads();
|
||
socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] });
|
||
delete _syncProgressCallbacks[playlistId];
|
||
setTimeout(() => {
|
||
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
|
||
removeDiscoverDownload(playlistId);
|
||
}
|
||
}, 30000);
|
||
}
|
||
};
|
||
}
|
||
|
||
const checkInterval = setInterval(async () => {
|
||
try {
|
||
// Check if download still exists
|
||
if (!discoverDownloads[playlistId]) {
|
||
clearInterval(checkInterval);
|
||
if (_syncProgressCallbacks[playlistId]) {
|
||
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] });
|
||
delete _syncProgressCallbacks[playlistId];
|
||
}
|
||
return;
|
||
}
|
||
|
||
// First check if there's an active download process (modal-based downloads)
|
||
const activeProcess = activeDownloadProcesses[playlistId];
|
||
if (activeProcess) {
|
||
console.log(`📂 [DOWNLOAD BAR] Found active process for ${playlistId}, status: ${activeProcess.status}`);
|
||
|
||
if (activeProcess.status === 'complete') {
|
||
console.log(`✅ [DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`);
|
||
discoverDownloads[playlistId].status = 'completed';
|
||
updateDiscoverDownloadBar();
|
||
updateDashboardDownloads();
|
||
clearInterval(checkInterval);
|
||
|
||
// Auto-remove completed downloads after 30 seconds
|
||
setTimeout(() => {
|
||
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
|
||
removeDiscoverDownload(playlistId);
|
||
}
|
||
}, 30000);
|
||
}
|
||
return; // Continue monitoring
|
||
}
|
||
|
||
// Check sync status API (for sync-based downloads)
|
||
if (socketConnected) return; // Phase 5: WS handles sync status
|
||
const response = await fetch(`/api/sync/status/${playlistId}`);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
notFoundCount = 0; // Reset counter if found
|
||
|
||
console.log(`🔄 [DOWNLOAD BAR] Sync status for ${playlistId}: ${data.status}`);
|
||
|
||
if (data.status === 'complete') {
|
||
console.log(`✅ [DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`);
|
||
discoverDownloads[playlistId].status = 'completed';
|
||
updateDiscoverDownloadBar();
|
||
updateDashboardDownloads();
|
||
clearInterval(checkInterval);
|
||
|
||
// Auto-remove completed downloads after 30 seconds
|
||
setTimeout(() => {
|
||
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
|
||
removeDiscoverDownload(playlistId);
|
||
}
|
||
}, 30000);
|
||
}
|
||
} else if (response.status === 404) {
|
||
notFoundCount++;
|
||
console.log(`🔍 [DOWNLOAD BAR] Sync not found for ${playlistId} (attempt ${notFoundCount}/${maxNotFoundAttempts})`);
|
||
|
||
// Only remove after multiple attempts (give it time to start)
|
||
if (notFoundCount >= maxNotFoundAttempts) {
|
||
console.log(`⏹️ [DOWNLOAD BAR] Sync not found after ${maxNotFoundAttempts} attempts, removing`);
|
||
clearInterval(checkInterval);
|
||
removeDiscoverDownload(playlistId);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ [DOWNLOAD BAR] Error monitoring ${playlistId}:`, error);
|
||
}
|
||
}, 2000); // Check every 2 seconds
|
||
}
|
||
|
||
/**
|
||
* Remove a download from the bar
|
||
*/
|
||
function removeDiscoverDownload(playlistId) {
|
||
console.log(`🗑️ Removing discover download: ${playlistId}`);
|
||
delete discoverDownloads[playlistId];
|
||
updateDiscoverDownloadBar();
|
||
updateDashboardDownloads();
|
||
saveDiscoverDownloadSnapshot(); // Save state after removal
|
||
}
|
||
|
||
/**
|
||
* Update the discover download sidebar UI
|
||
*/
|
||
function updateDiscoverDownloadBar() {
|
||
const downloadSidebar = document.getElementById('discover-download-sidebar');
|
||
const bubblesContainer = document.getElementById('discover-download-bubbles');
|
||
const countElement = document.getElementById('discover-download-count');
|
||
|
||
console.log(`🔄 [DOWNLOAD SIDEBAR] Updating sidebar - found elements:`, {
|
||
downloadSidebar: !!downloadSidebar,
|
||
bubblesContainer: !!bubblesContainer,
|
||
countElement: !!countElement
|
||
});
|
||
|
||
if (!downloadSidebar || !bubblesContainer || !countElement) {
|
||
console.warn('⚠️ [DOWNLOAD SIDEBAR] Missing elements, cannot update');
|
||
return;
|
||
}
|
||
|
||
const activeDownloads = Object.keys(discoverDownloads);
|
||
const count = activeDownloads.length;
|
||
|
||
console.log(`📊 [DOWNLOAD SIDEBAR] Updating with ${count} active downloads`);
|
||
|
||
// Update count
|
||
countElement.textContent = count;
|
||
|
||
// Show/hide sidebar
|
||
if (count === 0) {
|
||
console.log(`👁️ [DOWNLOAD SIDEBAR] No downloads, hiding sidebar`);
|
||
downloadSidebar.classList.add('hidden');
|
||
return;
|
||
} else {
|
||
console.log(`👁️ [DOWNLOAD SIDEBAR] ${count} downloads, showing sidebar`);
|
||
downloadSidebar.classList.remove('hidden');
|
||
}
|
||
|
||
// Update bubbles
|
||
bubblesContainer.innerHTML = activeDownloads.map(playlistId => {
|
||
const download = discoverDownloads[playlistId];
|
||
const isCompleted = download.status === 'completed';
|
||
const icon = isCompleted ? '✅' : '⏳';
|
||
|
||
// Use image if available, otherwise gradient background
|
||
const imageUrl = download.imageUrl || '';
|
||
const backgroundStyle = imageUrl ?
|
||
`background-image: url('${imageUrl}');` :
|
||
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
|
||
|
||
return `
|
||
<div class="discover-download-bubble">
|
||
<div class="discover-download-bubble-card ${isCompleted ? 'completed' : ''}"
|
||
onclick="openDiscoverDownloadModal('${playlistId}')"
|
||
title="${escapeHtml(download.name)} - Click to view">
|
||
<div class="discover-download-bubble-image" style="${backgroundStyle}"></div>
|
||
<div class="discover-download-bubble-overlay"></div>
|
||
<div class="discover-download-bubble-content">
|
||
<span class="discover-download-bubble-icon">${icon}</span>
|
||
</div>
|
||
</div>
|
||
<div class="discover-download-bubble-name">${escapeHtml(download.name)}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
console.log(`📊 Updated discover download sidebar: ${count} active downloads`);
|
||
|
||
// Save snapshot after UI update
|
||
saveDiscoverDownloadSnapshot();
|
||
}
|
||
|
||
/**
|
||
* Open download modal for a discover playlist
|
||
*/
|
||
async function openDiscoverDownloadModal(playlistId) {
|
||
console.log(`📂 [DOWNLOAD BAR] Opening download modal for: ${playlistId}`);
|
||
|
||
// Check if there's an active download process with modal
|
||
let process = activeDownloadProcesses[playlistId];
|
||
|
||
console.log(`📋 [DOWNLOAD BAR] Process found:`, {
|
||
exists: !!process,
|
||
hasModalElement: !!(process && process.modalElement),
|
||
hasModalId: !!(process && process.modalId)
|
||
});
|
||
|
||
if (process) {
|
||
// Try modalElement first (album downloads)
|
||
if (process.modalElement) {
|
||
console.log(`✅ [DOWNLOAD BAR] Opening modal via modalElement`);
|
||
process.modalElement.style.display = 'flex';
|
||
return;
|
||
}
|
||
|
||
// Try modalId (sync downloads)
|
||
if (process.modalId) {
|
||
const modal = document.getElementById(process.modalId);
|
||
if (modal) {
|
||
console.log(`✅ [DOWNLOAD BAR] Opening modal via modalId: ${process.modalId}`);
|
||
modal.style.display = 'flex';
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If no process found, try to rehydrate from backend
|
||
console.log(`💧 [DOWNLOAD BAR] No modal found, attempting to rehydrate from backend...`);
|
||
const rehydrated = await rehydrateDiscoverDownloadModal(playlistId);
|
||
|
||
if (rehydrated) {
|
||
console.log(`✅ [DOWNLOAD BAR] Successfully rehydrated modal, opening it...`);
|
||
// Try again after rehydration
|
||
process = activeDownloadProcesses[playlistId];
|
||
if (process && process.modalElement) {
|
||
process.modalElement.style.display = 'flex';
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Fallback: show toast
|
||
const download = discoverDownloads[playlistId];
|
||
if (download) {
|
||
console.log(`ℹ️ [DOWNLOAD BAR] No modal found after rehydration attempt, showing toast`);
|
||
showToast(`Download: ${download.name} - ${download.status}`, 'info');
|
||
} else {
|
||
console.warn(`⚠️ [DOWNLOAD BAR] No download or process found for: ${playlistId}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize discover download sidebar on page load
|
||
*/
|
||
function initializeDiscoverDownloadBar() {
|
||
console.log('🎵 Initializing discover download sidebar...');
|
||
|
||
// Start with sidebar hidden (will be shown if downloads exist after hydration)
|
||
const downloadSidebar = document.getElementById('discover-download-sidebar');
|
||
if (downloadSidebar) {
|
||
downloadSidebar.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// --- Discover Download Modal Rehydration ---
|
||
|
||
async function rehydrateDiscoverDownloadModal(playlistId) {
|
||
/**
|
||
* Rehydrates a discover download modal from backend process data.
|
||
* Fetches tracks from backend API and recreates the modal (user-requested).
|
||
*/
|
||
try {
|
||
console.log(`💧 [REHYDRATE] Attempting to rehydrate modal for: ${playlistId}`);
|
||
|
||
// Check if there's an active backend process for this playlist
|
||
const batchResponse = await fetch(`/api/download_status/batch`);
|
||
if (!batchResponse.ok) {
|
||
console.log(`⚠️ [REHYDRATE] Failed to fetch batch info`);
|
||
return false;
|
||
}
|
||
|
||
const batchData = await batchResponse.json();
|
||
const batches = batchData.batches || {};
|
||
|
||
// Find the batch for this playlist (batches is an object with batch_id keys)
|
||
let batchId = null;
|
||
let batch = null;
|
||
for (const [id, batchStatus] of Object.entries(batches)) {
|
||
if (batchStatus.playlist_id === playlistId) {
|
||
batchId = id;
|
||
batch = batchStatus;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!batch || !batchId) {
|
||
console.log(`⚠️ [REHYDRATE] No active batch found for ${playlistId}`);
|
||
return false;
|
||
}
|
||
|
||
console.log(`✅ [REHYDRATE] Found active batch for ${playlistId}: ${batchId}`, batch);
|
||
|
||
// Get the download metadata from discoverDownloads
|
||
const downloadData = discoverDownloads[playlistId];
|
||
if (!downloadData) {
|
||
console.log(`⚠️ [REHYDRATE] No download metadata found for ${playlistId}`);
|
||
return false;
|
||
}
|
||
|
||
// Handle album downloads from Recent Releases
|
||
if (playlistId.startsWith('discover_album_')) {
|
||
const albumId = playlistId.replace('discover_album_', '');
|
||
console.log(`💧 [REHYDRATE] Album download - fetching album ${albumId}...`);
|
||
|
||
try {
|
||
const albumResponse = await fetch(`/api/spotify/album/${albumId}`);
|
||
if (!albumResponse.ok) {
|
||
console.error(`❌ [REHYDRATE] Failed to fetch album: ${albumResponse.status}`);
|
||
return false;
|
||
}
|
||
|
||
const albumData = await albumResponse.json();
|
||
if (!albumData.tracks || albumData.tracks.length === 0) {
|
||
console.error(`❌ [REHYDRATE] No tracks in album`);
|
||
return false;
|
||
}
|
||
|
||
// Convert tracks to expected format
|
||
const spotifyTracks = albumData.tracks.map(track => {
|
||
let artists = track.artists || [];
|
||
if (Array.isArray(artists)) {
|
||
artists = artists.map(a => a.name || a);
|
||
}
|
||
|
||
return {
|
||
id: track.id,
|
||
name: track.name,
|
||
artists: artists,
|
||
album: {
|
||
name: albumData.name || downloadData.name.split(' - ')[0],
|
||
images: downloadData.imageUrl ? [{ url: downloadData.imageUrl }] : []
|
||
},
|
||
duration_ms: track.duration_ms || 0
|
||
};
|
||
});
|
||
|
||
console.log(`✅ [REHYDRATE] Retrieved ${spotifyTracks.length} tracks for album`);
|
||
|
||
// Create modal
|
||
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
|
||
|
||
// Update process
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (process) {
|
||
process.status = 'running';
|
||
process.batchId = batchId;
|
||
subscribeToDownloadBatch(batchId);
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Start polling for status updates
|
||
startModalDownloadPolling(playlistId);
|
||
console.log(`✅ [REHYDRATE] Successfully rehydrated album modal with polling`);
|
||
return true;
|
||
}
|
||
return false;
|
||
|
||
} catch (error) {
|
||
console.error(`❌ [REHYDRATE] Error fetching album:`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Determine API endpoint based on playlist ID
|
||
let apiEndpoint;
|
||
if (playlistId === 'discover_release_radar') {
|
||
apiEndpoint = '/api/discover/release-radar';
|
||
} else if (playlistId === 'discover_discovery_weekly') {
|
||
apiEndpoint = '/api/discover/discovery-weekly';
|
||
} else if (playlistId === 'discover_seasonal_playlist') {
|
||
apiEndpoint = '/api/discover/seasonal-playlist';
|
||
} else if (playlistId === 'discover_popular_picks') {
|
||
apiEndpoint = '/api/discover/popular-picks';
|
||
} else if (playlistId === 'discover_hidden_gems') {
|
||
apiEndpoint = '/api/discover/hidden-gems';
|
||
} else if (playlistId === 'discover_discovery_shuffle') {
|
||
apiEndpoint = '/api/discover/discovery-shuffle';
|
||
} else if (playlistId === 'build_playlist_custom') {
|
||
apiEndpoint = '/api/discover/build-playlist';
|
||
} else if (playlistId.startsWith('discover_lb_')) {
|
||
// ListenBrainz playlist - fetch from cache
|
||
const identifier = playlistId.replace('discover_lb_', '');
|
||
const tracks = listenbrainzTracksCache[identifier];
|
||
if (!tracks || tracks.length === 0) {
|
||
console.log(`⚠️ [REHYDRATE] No ListenBrainz tracks in cache for ${identifier}`);
|
||
return false;
|
||
}
|
||
|
||
// Convert to Spotify format
|
||
const spotifyTracks = tracks.map(track => ({
|
||
id: track.mbid || `listenbrainz_${track.track_name}_${track.artist_name}`.replace(/[^a-z0-9]/gi, '_'), // Generate ID if missing
|
||
name: track.track_name,
|
||
artists: [{ name: cleanArtistName(track.artist_name) }], // Proper Spotify format
|
||
album: {
|
||
name: track.album_name,
|
||
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
|
||
},
|
||
duration_ms: track.duration_ms || 0,
|
||
mbid: track.mbid
|
||
}));
|
||
|
||
// Create modal and update process
|
||
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (process) {
|
||
process.status = 'running';
|
||
process.batchId = batchId;
|
||
subscribeToDownloadBatch(batchId);
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Start polling for status updates
|
||
startModalDownloadPolling(playlistId);
|
||
console.log(`✅ [REHYDRATE] Successfully rehydrated ListenBrainz modal with polling`);
|
||
return true;
|
||
}
|
||
return false;
|
||
} else if (playlistId.startsWith('listenbrainz_')) {
|
||
// ListenBrainz download from discovery modal - get from backend state
|
||
const mbid = playlistId.replace('listenbrainz_', '');
|
||
console.log(`💧 [REHYDRATE] ListenBrainz download - fetching state for MBID: ${mbid}`);
|
||
|
||
try {
|
||
// Fetch ListenBrainz state from backend
|
||
const stateResponse = await fetch(`/api/listenbrainz/state/${mbid}`);
|
||
if (!stateResponse.ok) {
|
||
console.log(`⚠️ [REHYDRATE] Failed to fetch ListenBrainz state`);
|
||
return false;
|
||
}
|
||
|
||
const stateData = await stateResponse.json();
|
||
if (!stateData || !stateData.discovery_results) {
|
||
console.log(`⚠️ [REHYDRATE] No discovery results in ListenBrainz state`);
|
||
return false;
|
||
}
|
||
|
||
// Convert discovery results to Spotify tracks
|
||
const spotifyTracks = stateData.discovery_results
|
||
.filter(result => result.spotify_data)
|
||
.map(result => {
|
||
const track = result.spotify_data;
|
||
// Ensure artists is in proper Spotify format: [{name: ...}]
|
||
let artistsArray = [];
|
||
if (track.artists && Array.isArray(track.artists)) {
|
||
artistsArray = track.artists.map(artist => {
|
||
if (typeof artist === 'string') {
|
||
return { name: artist };
|
||
} else if (artist && artist.name) {
|
||
return { name: artist.name };
|
||
} else {
|
||
return { name: String(artist || 'Unknown Artist') };
|
||
}
|
||
});
|
||
} else if (track.artists && typeof track.artists === 'string') {
|
||
artistsArray = [{ name: track.artists }];
|
||
} else {
|
||
artistsArray = [{ name: 'Unknown Artist' }];
|
||
}
|
||
return {
|
||
id: track.id,
|
||
name: track.name,
|
||
artists: artistsArray,
|
||
album: track.album || { name: 'Unknown Album', images: [] },
|
||
duration_ms: track.duration_ms || 0,
|
||
external_urls: track.external_urls || {}
|
||
};
|
||
});
|
||
|
||
if (spotifyTracks.length === 0) {
|
||
console.log(`⚠️ [REHYDRATE] No Spotify tracks in ListenBrainz discovery results`);
|
||
return false;
|
||
}
|
||
|
||
console.log(`✅ [REHYDRATE] Retrieved ${spotifyTracks.length} tracks from ListenBrainz state`);
|
||
|
||
// Create modal and update process
|
||
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (process) {
|
||
process.status = 'running';
|
||
process.batchId = batchId;
|
||
subscribeToDownloadBatch(batchId);
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Start polling for status updates
|
||
startModalDownloadPolling(playlistId);
|
||
console.log(`✅ [REHYDRATE] Successfully rehydrated ListenBrainz download modal with polling`);
|
||
return true;
|
||
}
|
||
return false;
|
||
|
||
} catch (error) {
|
||
console.error(`❌ [REHYDRATE] Error fetching ListenBrainz state:`, error);
|
||
return false;
|
||
}
|
||
} else {
|
||
console.error(`❌ [REHYDRATE] Unknown discover playlist type: ${playlistId}`);
|
||
return false;
|
||
}
|
||
|
||
// Fetch tracks from API
|
||
console.log(`📡 [REHYDRATE] Fetching tracks from ${apiEndpoint}...`);
|
||
const response = await fetch(apiEndpoint);
|
||
if (!response.ok) {
|
||
console.error(`❌ [REHYDRATE] Failed to fetch tracks: ${response.status}`);
|
||
return false;
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (!data.success || !data.tracks) {
|
||
console.error(`❌ [REHYDRATE] Invalid track data:`, data);
|
||
return false;
|
||
}
|
||
|
||
const tracks = data.tracks;
|
||
console.log(`✅ [REHYDRATE] Retrieved ${tracks.length} tracks`);
|
||
|
||
// Transform tracks to Spotify format
|
||
const spotifyTracks = tracks.map(track => {
|
||
let spotifyTrack;
|
||
if (track.track_data_json) {
|
||
spotifyTrack = track.track_data_json;
|
||
} else {
|
||
spotifyTrack = {
|
||
id: track.spotify_track_id,
|
||
name: track.track_name,
|
||
artists: [{ name: track.artist_name }],
|
||
album: {
|
||
name: track.album_name,
|
||
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
|
||
},
|
||
duration_ms: track.duration_ms || 0
|
||
};
|
||
}
|
||
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
|
||
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
|
||
}
|
||
return spotifyTrack;
|
||
});
|
||
|
||
// Create the modal
|
||
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
|
||
|
||
// Update process with batch info
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (process) {
|
||
process.status = 'running';
|
||
process.batchId = batchId;
|
||
subscribeToDownloadBatch(batchId);
|
||
|
||
// Update button states
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Start polling for status updates
|
||
startModalDownloadPolling(playlistId);
|
||
|
||
// Don't hide the modal - user clicked to open it
|
||
console.log(`✅ [REHYDRATE] Successfully rehydrated modal for ${downloadData.name} with polling`);
|
||
return true;
|
||
} else {
|
||
console.error(`❌ [REHYDRATE] Failed to find rehydrated process for ${playlistId}`);
|
||
return false;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`❌ [REHYDRATE] Error rehydrating discover download modal:`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// --- Discover Download Snapshot System ---
|
||
|
||
let discoverSnapshotSaveTimeout = null; // Debounce snapshot saves
|
||
|
||
async function saveDiscoverDownloadSnapshot() {
|
||
/**
|
||
* Saves current discoverDownloads state to backend for persistence.
|
||
* Debounced to prevent excessive backend calls.
|
||
*/
|
||
|
||
// Clear any existing timeout
|
||
if (discoverSnapshotSaveTimeout) {
|
||
clearTimeout(discoverSnapshotSaveTimeout);
|
||
}
|
||
|
||
// Debounce the actual save
|
||
discoverSnapshotSaveTimeout = setTimeout(async () => {
|
||
try {
|
||
const downloadCount = Object.keys(discoverDownloads).length;
|
||
|
||
// Don't save empty state
|
||
if (downloadCount === 0) {
|
||
console.log('📸 Skipping discover snapshot save - no downloads to save');
|
||
return;
|
||
}
|
||
|
||
console.log(`📸 Saving discover download snapshot: ${downloadCount} downloads`);
|
||
|
||
// Prepare snapshot data (clean format)
|
||
const cleanDownloads = {};
|
||
for (const [playlistId, downloadData] of Object.entries(discoverDownloads)) {
|
||
cleanDownloads[playlistId] = {
|
||
name: downloadData.name,
|
||
type: downloadData.type,
|
||
status: downloadData.status,
|
||
virtualPlaylistId: downloadData.virtualPlaylistId,
|
||
imageUrl: downloadData.imageUrl,
|
||
startTime: downloadData.startTime instanceof Date ? downloadData.startTime.toISOString() : downloadData.startTime
|
||
};
|
||
}
|
||
|
||
const response = await fetch('/api/discover_downloads/snapshot', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
downloads: cleanDownloads
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
console.log(`✅ Discover download snapshot saved: ${downloadCount} downloads`);
|
||
} else {
|
||
console.error('❌ Failed to save discover download snapshot:', data.error);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error saving discover download snapshot:', error);
|
||
}
|
||
}, 1000); // 1 second debounce
|
||
}
|
||
|
||
async function hydrateDiscoverDownloadsFromSnapshot() {
|
||
/**
|
||
* Hydrates discover downloads from backend snapshot with live status.
|
||
* Called on page load to restore download state.
|
||
*/
|
||
try {
|
||
console.log('🔄 Loading discover download snapshot from backend...');
|
||
|
||
const response = await fetch('/api/discover_downloads/hydrate');
|
||
const data = await response.json();
|
||
|
||
if (!data.success) {
|
||
console.error('❌ Failed to load discover download snapshot:', data.error);
|
||
return;
|
||
}
|
||
|
||
const downloads = data.downloads || {};
|
||
const stats = data.stats || {};
|
||
|
||
console.log(`🔄 Loaded discover snapshot: ${stats.total_downloads || 0} downloads, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
|
||
|
||
if (Object.keys(downloads).length === 0) {
|
||
console.log('ℹ️ No discover downloads to hydrate');
|
||
return;
|
||
}
|
||
|
||
// Clear existing state
|
||
discoverDownloads = {};
|
||
|
||
// Restore discoverDownloads with hydrated data
|
||
for (const [playlistId, downloadData] of Object.entries(downloads)) {
|
||
discoverDownloads[playlistId] = {
|
||
name: downloadData.name,
|
||
type: downloadData.type,
|
||
status: downloadData.status, // Live status from backend
|
||
virtualPlaylistId: downloadData.virtualPlaylistId,
|
||
imageUrl: downloadData.imageUrl,
|
||
startTime: new Date(downloadData.startTime)
|
||
};
|
||
|
||
console.log(`🔄 Hydrated download: ${downloadData.name} (${downloadData.status})`);
|
||
|
||
// Start monitoring for any in-progress downloads
|
||
if (downloadData.status === 'in_progress') {
|
||
console.log(`📡 Starting monitoring for: ${downloadData.name}`);
|
||
monitorDiscoverDownload(playlistId);
|
||
}
|
||
}
|
||
|
||
// Don't update UI here - it will be updated when user navigates to discover page
|
||
// This allows hydration to work even if page loads on a different tab
|
||
|
||
const totalDownloads = Object.keys(discoverDownloads).length;
|
||
console.log(`✅ Successfully hydrated ${totalDownloads} discover downloads (UI will update on discover page navigation)`);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error hydrating discover downloads from snapshot:', error);
|
||
}
|
||
}
|
||
|
||
// Initialize on page load
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', initializeDiscoverDownloadBar);
|
||
} else {
|
||
initializeDiscoverDownloadBar();
|
||
}
|
||
|
||
// ============================================================================
|