soulsync/webui/static/artists.js
Broque Thomas 6a76405444 Add Similar Artists to standalone /artist-detail page, hide library-only UI for source artists
First increment of the artist-detail unification redesign. Delivers
the two most-visible missing pieces for source artists without touching
the hero layout — that's a later commit.

Changes:
  - HTML: new #ad-similar-artists-section inside #artist-detail-main
    (scoped IDs with 'ad-' prefix so they don't collide with the inline
    Artists page, which has the same section using base IDs).
  - shared-helpers.js: similar-artists helpers (loadSimilarArtists +
    display/progressive/createBubble + lazy image loader) moved out of
    artists.js. New _resolveSimilarArtistsTargets() resolver picks
    whichever candidate set has a `.page.active` ancestor, so the same
    function works on both the inline Artists page and the standalone
    artist-detail page without caller changes.
  - library.js populateArtistDetailPage: sets
    document.body.dataset.artistSource = 'library' | 'source' before
    rendering, and fires loadSimilarArtists(artist.name) after
    populating the rest of the page.
  - style.css: body[data-artist-source='source'] rules hide
    library-only UI on the artist-detail page — Enhanced view toggle,
    Status (owned/missing) filter, completion bars, enrichment
    coverage, Top Tracks sidebar, Radio / Enhance Quality buttons,
    "X owned / Y missing" section-stats counts. CSS-only, additive,
    library artists completely unaffected.

Impact today:
  - Library artists: Similar Artists section now appears at the
    bottom of their detail page (previously only the inline Artists
    page had it). All other UI unchanged.
  - Source artists: still route to the inline Artists page (Part B
    reverted earlier this session). The standalone page is now
    source-ready infrastructure-wise, but source artists don't reach
    it yet. A later commit will re-migrate source callers to the
    standalone page once the hero rendering is also source-ready.

artists.js shrinks from 1903 -> 1584 lines (similar-artists block
extracted). shared-helpers.js grows correspondingly. 357/357 tests
still pass. No version bump — this is still 2.39 pending.
2026-04-22 16:19:25 -07:00

1584 lines
62 KiB
JavaScript

// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY
// ============================================================================
/**
* Initialize the artists page when navigated to (only runs once)
*/
function initializeArtistsPage() {
console.log('🎵 Initializing Artists Page (first time)');
// Get DOM elements
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
const searchStatus = document.getElementById('artists-search-status');
const backButton = document.getElementById('artists-back-button');
const detailBackButton = document.getElementById('artist-detail-back-button');
// Set up event listeners (only need to do this once)
if (searchInput) {
searchInput.addEventListener('input', handleArtistsSearchInput);
searchInput.addEventListener('keypress', handleArtistsSearchKeypress);
}
if (headerSearchInput) {
headerSearchInput.addEventListener('input', handleArtistsHeaderSearchInput);
headerSearchInput.addEventListener('keypress', handleArtistsSearchKeypress);
}
if (backButton) {
backButton.addEventListener('click', () => showArtistsSearchState());
}
if (detailBackButton) {
detailBackButton.addEventListener('click', () => {
// If the user searched within the Artists page, back returns to the
// results list so they can pick a different artist.
if (artistsPageState.searchResults && artistsPageState.searchResults.length > 0) {
showArtistsResultsState();
return;
}
// Otherwise the user reached this detail view from elsewhere (Search,
// Discover, watchlist, etc.). The Artists page is no longer a sidebar
// entry, so there's nothing useful to fall back to here — let the
// browser take them back to wherever they came from, or drop them on
// Search (the go-forward way to find another artist).
if (window.history.length > 1) {
window.history.back();
} else {
navigateToPage('search');
}
});
}
// Initialize tabs (only need to do this once)
initializeArtistTabs();
// Mark as initialized
artistsPageState.isInitialized = true;
// Restore previous state instead of always resetting to search
restoreArtistsPageState();
console.log('✅ Artists Page initialized successfully (ready for navigation)');
}
/**
* Restore the artists page to its previous state
*/
function restoreArtistsPageState() {
console.log(`🔄 Restoring artists page state: ${artistsPageState.currentView}`);
switch (artistsPageState.currentView) {
case 'results':
// Restore search results state
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
console.log(`📦 Restoring search results for: "${artistsPageState.searchQuery}"`);
// Restore search input values
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
if (searchInput) searchInput.value = artistsPageState.searchQuery;
if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery;
// Display the cached results
displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults);
} else {
// No valid results state, fall back to search
showArtistsSearchState();
}
break;
case 'detail':
// Restore artist detail state
if (artistsPageState.selectedArtist && artistsPageState.artistDiscography) {
console.log(`🎤 Restoring artist detail for: ${artistsPageState.selectedArtist.name}`);
// First restore search results if they exist
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
if (searchInput) searchInput.value = artistsPageState.searchQuery;
if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery;
}
// Show artist detail state
showArtistDetailState();
// Update artist info in header
updateArtistDetailHeader(artistsPageState.selectedArtist);
// Display cached discography
if (artistsPageState.artistDiscography.albums || artistsPageState.artistDiscography.singles) {
displayArtistDiscography(artistsPageState.artistDiscography);
// Restore cached completion data instead of re-scanning
restoreCachedCompletionData(artistsPageState.selectedArtist.id);
}
} else {
// No valid detail state, fall back to search or results
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults);
} else {
showArtistsSearchState();
}
}
break;
default:
case 'search':
// Show search state (but preserve any existing search query)
if (artistsPageState.searchQuery) {
const searchInput = document.getElementById('artists-search-input');
if (searchInput) searchInput.value = artistsPageState.searchQuery;
}
showArtistsSearchState();
break;
}
}
/**
* Handle search input with debouncing
*/
function handleArtistsSearchInput(event) {
const query = event.target.value.trim();
updateArtistsSearchStatus('searching');
// Clear existing timeout
if (artistsSearchTimeout) {
clearTimeout(artistsSearchTimeout);
}
// Cancel any active search
if (artistsSearchController) {
artistsSearchController.abort();
}
if (query === '') {
updateArtistsSearchStatus('default');
return;
}
// Set up new debounced search
artistsSearchTimeout = setTimeout(() => {
performArtistsSearch(query);
}, 1000); // 1 second debounce
}
/**
* Handle header search input (already in results state)
*/
function handleArtistsHeaderSearchInput(event) {
const query = event.target.value.trim();
// Update main search input to match
const mainInput = document.getElementById('artists-search-input');
if (mainInput) {
mainInput.value = query;
}
// Trigger search with same debouncing logic
handleArtistsSearchInput(event);
}
/**
* Handle Enter key press in search inputs
*/
function handleArtistsSearchKeypress(event) {
if (event.key === 'Enter') {
event.preventDefault();
const query = event.target.value.trim();
if (query && query !== artistsPageState.searchQuery) {
// Clear timeout and search immediately
if (artistsSearchTimeout) {
clearTimeout(artistsSearchTimeout);
}
performArtistsSearch(query);
}
}
}
/**
* Perform artist search with API call
*/
async function performArtistsSearch(query) {
console.log(`🔍 Searching for artists: "${query}"`);
// Check cache first
if (artistsPageState.cache.searches[query]) {
console.log('📦 Using cached search results');
displayArtistsResults(query, artistsPageState.cache.searches[query]);
return;
}
// Update status
updateArtistsSearchStatus('searching');
// Show loading cards immediately if we're in results view
if (artistsPageState.currentView === 'results') {
showSearchLoadingCards();
}
try {
// Set up abort controller
artistsSearchController = new AbortController();
const response = await fetch('/api/match/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: query,
context: 'artist'
}),
signal: artistsSearchController.signal
});
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
const data = await response.json();
console.log(`✅ Found ${data.results?.length || 0} artists`);
// Transform the results to flatten the nested artist data
const transformedResults = (data.results || []).map(result => {
// Extract artist data from the nested structure
const artist = result.artist || result;
return {
id: artist.id,
name: artist.name,
image_url: artist.image_url,
genres: artist.genres,
popularity: artist.popularity,
confidence: result.confidence || 0
};
});
console.log('🔧 Transformed results:', transformedResults);
// Cache the transformed results
artistsPageState.cache.searches[query] = transformedResults;
// Display results
displayArtistsResults(query, transformedResults);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('❌ Artist search failed:', error);
// Provide specific error messages based on the error type
let errorMessage = 'Search failed. Please try again.';
if (error.message.includes('401') || error.message.includes('authentication')) {
errorMessage = 'Spotify not authenticated. Please check your API settings.';
} else if (error.message.includes('network') || error.message.includes('fetch')) {
errorMessage = 'Network error. Please check your connection.';
} else if (error.message.includes('timeout')) {
errorMessage = 'Search timed out. Please try again.';
}
updateArtistsSearchStatus('error', errorMessage);
}
} finally {
artistsSearchController = null;
}
}
/**
* Display artist search results
*/
function displayArtistsResults(query, results) {
console.log(`📊 Displaying ${results.length} artist results`);
// Update state
artistsPageState.searchQuery = query;
artistsPageState.searchResults = results;
artistsPageState.currentView = 'results';
// Update header search input if different
const headerInput = document.getElementById('artists-header-search-input');
if (headerInput && headerInput.value !== query) {
headerInput.value = query;
}
// Show results state
showArtistsResultsState();
// Populate results
const container = document.getElementById('artists-cards-container');
if (!container) return;
if (results.length === 0) {
container.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; color: rgba(255, 255, 255, 0.6);">
<div style="font-size: 24px; margin-bottom: 12px;">🔍</div>
<div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">No artists found</div>
<div style="font-size: 14px;">Try a different search term</div>
</div>
`;
return;
}
// Create artist cards
container.innerHTML = results.map(result => createArtistCardHTML(result)).join('');
observeLazyBackgrounds(container);
// Add event listeners to cards
container.querySelectorAll('.artist-card').forEach((card, index) => {
card.addEventListener('click', () => selectArtistForDetail(results[index]));
// Extract colors from artist image for dynamic glow
const artist = results[index];
if (artist.image_url) {
extractImageColors(artist.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
});
// Update watchlist status for all cards
updateArtistCardWatchlistStatus();
// Lazy load missing artist images
console.log('🖼️ Starting lazy load for artist images on Artists page...');
if (typeof lazyLoadArtistImages === 'function') {
lazyLoadArtistImages(container);
} else if (typeof window.lazyLoadArtistImages === 'function') {
window.lazyLoadArtistImages(container);
} else {
console.error('❌ lazyLoadArtistImages function not found!');
}
// Add mouse wheel horizontal scrolling
container.addEventListener('wheel', (event) => {
if (event.deltaY !== 0) {
event.preventDefault();
container.scrollLeft += event.deltaY;
}
});
}
/**
* Lazy load artist images for cards that don't have images yet.
* Fetches images asynchronously so search results appear immediately.
*/
async function lazyLoadArtistImages(container) {
if (!container) {
console.error('❌ lazyLoadArtistImages: container is null');
return;
}
// Find all cards that need images
const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]');
if (cardsNeedingImages.length === 0) {
console.log('✅ All artist cards have images');
return;
}
console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`);
// Load images in parallel (but with a small batch to avoid overwhelming the server)
const batchSize = 5;
const cards = Array.from(cardsNeedingImages);
for (let i = 0; i < cards.length; i += batchSize) {
const batch = cards.slice(i, i + batchSize);
await Promise.all(batch.map(async (card) => {
const artistId = card.dataset.artistId;
if (!artistId) {
console.warn('⚠️ Card missing artistId:', card);
return;
}
try {
console.log(`🔄 Fetching image for artist ${artistId}...`);
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
console.log(`📥 Got response for ${artistId}:`, data);
if (data.success && data.image_url) {
// Update the card's background image
// Handle both card types (suggestion-card and artist-card)
if (card.classList.contains('suggestion-card')) {
card.style.backgroundImage = `url(${data.image_url})`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
} else if (card.classList.contains('artist-card')) {
const bgElement = card.querySelector('.artist-card-background');
if (bgElement) {
// Clear the gradient first, then set the image
bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`;
}
}
card.dataset.needsImage = 'false';
console.log(`✅ Loaded image for artist ${artistId}`);
}
} catch (error) {
console.error(`❌ Failed to load image for artist ${artistId}:`, error);
}
}));
}
console.log('✅ Finished lazy loading artist images');
}
// Make function globally accessible
window.lazyLoadArtistImages = lazyLoadArtistImages;
/**
* Create HTML for an artist card
*/
function createArtistCardHTML(artist) {
const imageUrl = artist.image_url || '';
const genres = artist.genres && artist.genres.length > 0 ?
artist.genres.slice(0, 3).join(', ') : 'Various genres';
const popularity = artist.popularity || 0;
// Use data-bg-src for lazy background loading via IntersectionObserver
const backgroundAttr = imageUrl ?
`data-bg-src="${imageUrl}"` :
`style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);"`;
// Format popularity as a percentage for better UX
const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown';
// Track if image needs to be lazy loaded
const needsImage = imageUrl ? 'false' : 'true';
// Check for MusicBrainz ID
let mbIconHTML = '';
if (artist.musicbrainz_id) {
mbIconHTML = `
<div class="mb-card-icon" title="View on MusicBrainz" onclick="event.stopPropagation(); window.open('https://musicbrainz.org/artist/${artist.musicbrainz_id}', '_blank')">
<img src="${MUSICBRAINZ_LOGO_URL}" style="width: 20px; height: auto; display: block;">
</div>
`;
}
return `
<div class="artist-card" data-artist-id="${artist.id}" data-needs-image="${needsImage}">
${mbIconHTML}
<div class="artist-card-background" ${backgroundAttr}></div>
<div class="artist-card-overlay"></div>
<div class="artist-card-content">
<div class="artist-card-name">${escapeHtml(artist.name)}</div>
<div class="artist-card-genres">${escapeHtml(genres)}</div>
<div class="artist-card-popularity">
<span class="popularity-icon">🔥</span>
<span>${popularityText}</span>
</div>
<div class="artist-card-actions">
<div class="watchlist-btn-group">
<button class="watchlist-toggle-btn" data-artist-id="${artist.id}" data-artist-name="${escapeHtml(artist.name)}" onclick="toggleWatchlist(event, '${artist.id}', '${escapeForInlineJs(artist.name)}')">
<span class="watchlist-icon">👁️</span>
<span class="watchlist-text">Add to Watchlist</span>
</button>
<button class="watchlist-settings-btn hidden" data-artist-id="${artist.id}" data-artist-name="${escapeHtml(artist.name)}" onclick="event.stopPropagation(); openWatchlistArtistConfigModal('${artist.id}', '${escapeForInlineJs(artist.name)}')" title="Watchlist Settings">&#9881;</button>
</div>
</div>
</div>
</div>
`;
}
/**
* Select an artist and show their discography
*/
async function selectArtistForDetail(artist, options = {}) {
console.log(`🎤 Selected artist: ${artist.name}`);
// Cancel any ongoing completion check from previous artist
if (artistCompletionController) {
console.log('⏹️ Canceling previous artist completion check');
artistCompletionController.abort();
artistCompletionController = null;
}
// Cancel any ongoing similar artists stream from previous artist
if (similarArtistsController) {
console.log('⏹️ Canceling previous similar artists stream');
similarArtistsController.abort();
similarArtistsController = null;
}
// Update state
artistsPageState.selectedArtist = artist;
artistsPageState.currentView = 'detail';
artistsPageState.sourceOverride = options.source || artist.source || null;
artistsPageState.pluginOverride = options.plugin || null;
// Show detail state
showArtistDetailState();
// Update artist info in header
updateArtistDetailHeader(artist);
// Load discography (pass artist name for cross-source fallback)
await loadArtistDiscography(artist.id, artist.name, artistsPageState.sourceOverride, options.plugin);
}
/**
* Load artist's discography from Spotify or iTunes
* @param {string} artistId - Artist ID (Spotify or iTunes format)
* @param {string} [artistName] - Optional artist name for fallback searches
*/
async function loadArtistDiscography(artistId, artistName = null, sourceOverride = null, pluginOverride = null) {
console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName}, source: ${sourceOverride || 'auto'})`);
// Use source-prefixed cache key to avoid ID collisions between sources
const cacheKey = sourceOverride ? `${sourceOverride}:${artistId}` : artistId;
// Check cache first
if (artistsPageState.cache.discography[cacheKey]) {
console.log('📦 Using cached discography');
const cachedDiscography = artistsPageState.cache.discography[cacheKey];
if (artistsPageState.selectedArtist) {
artistsPageState.selectedArtist = {
...artistsPageState.selectedArtist,
source: cachedDiscography.source || sourceOverride || artistsPageState.selectedArtist.source || null,
};
}
artistsPageState.sourceOverride = cachedDiscography.source || sourceOverride || artistsPageState.sourceOverride || null;
displayArtistDiscography(cachedDiscography);
// Load similar artists in parallel (don't wait) — always uses primary source
loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => {
console.error('❌ Error loading similar artists:', err);
});
// Still check completion status for cached data
await checkDiscographyCompletion(artistId, cachedDiscography);
return;
}
try {
// Show loading states
showDiscographyLoading();
// Build URL with optional artist name and source override for fallback
let url = `/api/artist/${artistId}/discography`;
const params = new URLSearchParams();
if (artistName) params.set('artist_name', artistName);
if (sourceOverride) params.set('source', sourceOverride);
if (pluginOverride) params.set('plugin', pluginOverride);
if (params.toString()) url += `?${params.toString()}`;
// Call the real API endpoint
const response = await fetch(url);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Spotify not authenticated. Please check your API settings.');
}
throw new Error(`Failed to load discography: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const discography = {
albums: data.albums || [],
singles: data.singles || [],
source: data.source || sourceOverride || null,
};
// Keep the resolved metadata source on the selected artist so album clicks
// can pass it through to /api/album/<id>/tracks.
if (artistsPageState.selectedArtist) {
artistsPageState.selectedArtist = {
...artistsPageState.selectedArtist,
source: discography.source,
};
}
artistsPageState.sourceOverride = discography.source || artistsPageState.sourceOverride || null;
// Update selected artist with full details from backend (includes MusicBrainz ID)
if (data.artist) {
console.log('✨ Updating artist details with fresh data from backend');
artistsPageState.selectedArtist = {
...artistsPageState.selectedArtist,
...data.artist
};
}
// Merge artist_info enrichment from discography response
if (data.artist_info) {
artistsPageState.selectedArtist = {
...artistsPageState.selectedArtist,
artist_info: data.artist_info,
};
}
// Refresh header with all available data
updateArtistDetailHeader(artistsPageState.selectedArtist);
console.log(`✅ Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`);
// Cache the results (use source-prefixed key if source override active)
artistsPageState.cache.discography[cacheKey] = discography;
artistsPageState.artistDiscography = discography;
// Display results
displayArtistDiscography(discography);
// Load similar artists and check completion in parallel (don't wait)
loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => {
console.error('❌ Error loading similar artists:', err);
});
// Check completion status for all albums and singles
await checkDiscographyCompletion(artistId, discography);
} catch (error) {
console.error('❌ Failed to load discography:', error);
showDiscographyError(error.message);
}
}
/**
* Display artist's discography in tabs
*/
function displayArtistDiscography(discography) {
console.log(`📀 Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`);
// Show Download Discography button(s) if there are any releases
const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0);
const _discogWrap = document.getElementById('discog-download-wrap');
if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none';
const _discogBtnArtists = document.getElementById('discog-download-btn-artists');
if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none';
// Populate albums
const albumsContainer = document.getElementById('album-cards-container');
if (albumsContainer) {
if (discography.albums?.length > 0) {
albumsContainer.innerHTML = discography.albums.map(album => createAlbumCardHTML(album)).join('');
observeLazyBackgrounds(albumsContainer);
// Add dynamic glow effects and click handlers to album cards
albumsContainer.querySelectorAll('.album-card').forEach((card, index) => {
const album = discography.albums[index];
if (album.image_url) {
extractImageColors(album.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
// Add click handler for download missing tracks modal
card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums'));
card.style.cursor = 'pointer';
});
} else {
albumsContainer.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.6);">
<div style="font-size: 18px; margin-bottom: 8px;">💿</div>
<div style="font-size: 14px;">No albums found</div>
</div>
`;
}
}
// Populate singles
const singlesContainer = document.getElementById('singles-cards-container');
if (singlesContainer) {
if (discography.singles?.length > 0) {
singlesContainer.innerHTML = discography.singles.map(single => createAlbumCardHTML(single)).join('');
observeLazyBackgrounds(singlesContainer);
// Add dynamic glow effects and click handlers to singles cards
singlesContainer.querySelectorAll('.album-card').forEach((card, index) => {
const single = discography.singles[index];
if (single.image_url) {
extractImageColors(single.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
// Add click handler for download missing tracks modal
card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles'));
card.style.cursor = 'pointer';
});
} else {
singlesContainer.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.6);">
<div style="font-size: 18px; margin-bottom: 8px;">🎵</div>
<div style="font-size: 14px;">No singles or EPs found</div>
</div>
`;
}
}
// Auto-switch to Singles tab if no albums but has singles
if ((!discography.albums || discography.albums.length === 0) &&
discography.singles && discography.singles.length > 0) {
console.log('📀 No albums found, auto-switching to Singles & EPs tab');
// Switch to singles tab
const albumsTab = document.getElementById('albums-tab');
const singlesTab = document.getElementById('singles-tab');
const albumsContent = document.getElementById('albums-content');
const singlesContent = document.getElementById('singles-content');
if (albumsTab && singlesTab && albumsContent && singlesContent) {
// Remove active from albums
albumsTab.classList.remove('active');
albumsContent.classList.remove('active');
// Add active to singles
singlesTab.classList.add('active');
singlesContent.classList.add('active');
}
}
}
/**
* Load similar artists from MusicMap
*/
/**
* Restore cached completion data without re-scanning the database
*/
function restoreCachedCompletionData(artistId) {
console.log(`📦 Restoring cached completion data for artist: ${artistId}`);
const cachedData = artistsPageState.cache.completionData[artistId];
if (!cachedData) {
console.log('⚠️ No cached completion data found, skipping restoration');
return;
}
// Restore album completion overlays
if (cachedData.albums) {
cachedData.albums.forEach(albumCompletion => {
updateAlbumCompletionOverlay(albumCompletion, 'albums');
});
console.log(`✅ Restored ${cachedData.albums.length} album completion overlays`);
}
// Restore singles completion overlays
if (cachedData.singles) {
cachedData.singles.forEach(singleCompletion => {
updateAlbumCompletionOverlay(singleCompletion, 'singles');
});
console.log(`✅ Restored ${cachedData.singles.length} single completion overlays`);
}
}
/**
* Check completion status for entire discography with streaming updates
*/
/**
* Show error state on all completion overlays
*/
function showCompletionError() {
const allOverlays = document.querySelectorAll('.completion-overlay.checking');
allOverlays.forEach(overlay => {
overlay.classList.remove('checking');
overlay.classList.add('error');
overlay.innerHTML = '<span class="completion-status">Error</span>';
overlay.title = 'Failed to check completion status';
});
}
/**
* Create HTML for an album/single card
*/
function createAlbumCardHTML(album) {
const imageUrl = album.image_url || '';
const year = album.release_date ? new Date(album.release_date).getFullYear() : '';
const type = album.album_type === 'album' ? 'Album' :
album.album_type === 'single' ? 'Single' : 'EP';
// Use data-bg-src for lazy background loading via IntersectionObserver
const backgroundAttr = imageUrl ?
`data-bg-src="${imageUrl}"` :
`style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);"`;
return `
<div class="album-card" data-album-id="${album.id}" data-album-name="${escapeHtml(album.name)}" data-album-type="${album.album_type}" data-total-tracks="${album.total_tracks || 0}">
<div class="album-card-image" ${backgroundAttr}></div>
<div class="completion-overlay checking">
<span class="completion-status">Checking...</span>
</div>
<div class="album-card-content">
<div class="album-card-name" title="${escapeHtml(album.name)}">${escapeHtml(album.name)}</div>
<div class="album-card-year">${year || 'Unknown'}</div>
<div class="album-card-type">${type}</div>
</div>
</div>
`;
}
/**
* Initialize artist detail tabs
*/
function initializeArtistTabs() {
const tabButtons = document.querySelectorAll('.artist-tab');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabName = button.getAttribute('data-tab');
// Update button states
tabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update content states
tabContents.forEach(content => {
content.classList.remove('active');
if (content.id === `${tabName}-content`) {
content.classList.add('active');
}
});
console.log(`🔄 Switched to ${tabName} tab`);
});
});
}
/**
* State management functions
*/
function showArtistsSearchState() {
console.log('🔄 Showing search state');
// Cancel any ongoing completion check when navigating back to search
if (artistCompletionController) {
console.log('⏹️ Canceling completion check (navigating back to search)');
artistCompletionController.abort();
artistCompletionController = null;
}
// Cancel any ongoing similar artists stream when navigating back to search
if (similarArtistsController) {
console.log('⏹️ Canceling similar artists stream (navigating back to search)');
similarArtistsController.abort();
similarArtistsController = null;
}
const searchState = document.getElementById('artists-search-state');
const resultsState = document.getElementById('artists-results-state');
const detailState = document.getElementById('artist-detail-state');
if (searchState) {
searchState.classList.remove('hidden', 'fade-out');
}
if (resultsState) {
resultsState.classList.add('hidden');
resultsState.classList.remove('show');
}
if (detailState) {
detailState.classList.add('hidden');
detailState.classList.remove('show');
}
artistsPageState.currentView = 'search';
updateArtistsSearchStatus('default');
// Show artist downloads section if there are active downloads
showArtistDownloadsSection();
}
function showArtistsResultsState() {
console.log('🔄 Showing results state');
// Cancel any ongoing completion check when navigating back
if (artistCompletionController) {
console.log('⏹️ Canceling completion check (navigating back to results)');
artistCompletionController.abort();
artistCompletionController = null;
}
// Cancel any ongoing similar artists stream when navigating back
if (similarArtistsController) {
console.log('⏹️ Canceling similar artists stream (navigating back to results)');
similarArtistsController.abort();
similarArtistsController = null;
}
// Clear artist-specific data when navigating back to results
// This ensures that selecting the same artist again will trigger a fresh scan
if (artistsPageState.selectedArtist) {
const artistId = artistsPageState.selectedArtist.id;
console.log(`🗑️ Clearing cached data for artist: ${artistsPageState.selectedArtist.name}`);
// Clear artist-specific cache data
delete artistsPageState.cache.completionData[artistId];
delete artistsPageState.cache.discography[artistId];
// Clear artist state
artistsPageState.selectedArtist = null;
artistsPageState.artistDiscography = { albums: [], singles: [] };
}
const searchState = document.getElementById('artists-search-state');
const resultsState = document.getElementById('artists-results-state');
const detailState = document.getElementById('artist-detail-state');
if (searchState) {
searchState.classList.add('fade-out');
setTimeout(() => searchState.classList.add('hidden'), 200);
}
if (resultsState) {
resultsState.classList.remove('hidden');
setTimeout(() => resultsState.classList.add('show'), 50);
}
if (detailState) {
detailState.classList.add('hidden');
detailState.classList.remove('show');
}
artistsPageState.currentView = 'results';
}
function showArtistDetailState() {
console.log('🔄 Showing detail state');
const searchState = document.getElementById('artists-search-state');
const resultsState = document.getElementById('artists-results-state');
const detailState = document.getElementById('artist-detail-state');
if (searchState) {
searchState.classList.add('hidden', 'fade-out');
}
if (resultsState) {
resultsState.classList.add('hidden');
resultsState.classList.remove('show');
}
if (detailState) {
detailState.classList.remove('hidden');
setTimeout(() => detailState.classList.add('show'), 50);
}
artistsPageState.currentView = 'detail';
}
/**
* Update search status text and styling
*/
function updateArtistsSearchStatus(status, message = null) {
const statusElement = document.getElementById('artists-search-status');
if (!statusElement) return;
// Clear all status classes
statusElement.classList.remove('searching', 'error');
switch (status) {
case 'default':
statusElement.textContent = 'Start typing to search for artists';
break;
case 'searching':
statusElement.classList.add('searching');
statusElement.textContent = 'Searching for artists...';
break;
case 'error':
statusElement.classList.add('error');
statusElement.innerHTML = `
<div style="margin-bottom: 8px;">${message || 'Search failed. Please try again.'}</div>
<button onclick="retryLastSearch()" style="
background: rgba(29, 185, 84, 0.15);
color: rgba(29, 185, 84, 0.9);
border: 1px solid rgba(29, 185, 84, 0.3);
border-radius: 8px;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
" onmouseover="this.style.background='rgba(29, 185, 84, 0.25)'"
onmouseout="this.style.background='rgba(29, 185, 84, 0.15)'">
🔄 Retry Search
</button>
`;
break;
}
}
/**
* Retry the last search query
*/
function retryLastSearch() {
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
// Get the last search query from either input
const query = searchInput?.value?.trim() || headerSearchInput?.value?.trim() || artistsPageState.searchQuery;
if (query) {
console.log(`🔄 Retrying search for: "${query}"`);
performArtistsSearch(query);
}
}
/**
* Update artist detail header with artist info
*/
function updateArtistDetailHeader(artist) {
const _esc = (s) => (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const info = artist.artist_info || {};
const imageUrl = artist.image_url || info.image_url || '';
// Background blur
const heroBg = document.getElementById('artists-hero-bg');
if (heroBg) {
heroBg.style.backgroundImage = imageUrl ? `url('${imageUrl}')` : 'none';
}
// Artist image
const heroImage = document.getElementById('artists-hero-image');
if (heroImage) {
if (imageUrl) {
heroImage.style.backgroundImage = `url('${imageUrl}')`;
heroImage.innerHTML = '';
} else {
heroImage.style.backgroundImage = 'none';
heroImage.innerHTML = '<span class="artists-hero-image-fallback">🎤</span>';
// Lazy load
fetch(`/api/artist/${artist.id}/image`)
.then(r => r.json())
.then(d => {
if (d.success && d.image_url) {
heroImage.style.backgroundImage = `url('${d.image_url}')`;
heroImage.innerHTML = '';
if (heroBg) heroBg.style.backgroundImage = `url('${d.image_url}')`;
artist.image_url = d.image_url;
}
}).catch(() => { });
}
}
// Name
const heroName = document.getElementById('artists-hero-name');
if (heroName) heroName.textContent = artist.name || 'Unknown Artist';
// Badges (service links — real logos matching library page)
const badgesEl = document.getElementById('artists-hero-badges');
if (badgesEl) {
const _hb = (logo, fallback, title, url) => {
const inner = logo
? `<img src="${logo}" alt="${fallback}" onerror="this.parentNode.textContent='${fallback}'">`
: `<span style="font-size:9px;font-weight:700;">${fallback}</span>`;
if (url) return `<a class="artists-hero-badge" title="${title}" href="${_esc(url)}" target="_blank" rel="noopener noreferrer">${inner}</a>`;
return `<div class="artists-hero-badge" title="${title}">${inner}</div>`;
};
const badges = [];
if (info.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${info.spotify_artist_id}`));
if (info.musicbrainz_id || artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${info.musicbrainz_id || artist.musicbrainz_id}`));
if (info.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${info.deezer_id}`));
if (info.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${info.itunes_artist_id}`));
if (info.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', info.lastfm_url));
if (info.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', info.genius_url));
if (info.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${info.tidal_id}`));
if (info.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${info.qobuz_id}`));
if (info.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${info.discogs_id}`));
badgesEl.innerHTML = badges.join('');
}
// Genres (pill tags — merge with Last.fm tags, deduplicated)
const genresEl = document.getElementById('artists-hero-genres');
if (genresEl) {
let genres = info.genres || artist.genres || [];
// Merge Last.fm tags
const lfmTags = info.lastfm_tags || [];
if (Array.isArray(lfmTags) && lfmTags.length > 0) {
const existing = new Set(genres.map(g => g.toLowerCase()));
const newTags = lfmTags.filter(t => !existing.has(t.toLowerCase()));
genres = [...genres, ...newTags];
}
if (genres.length > 0) {
genresEl.innerHTML = genres.slice(0, 8).map(g =>
`<span class="artists-hero-genre-pill">${_esc(g)}</span>`
).join('');
} else {
genresEl.innerHTML = '';
}
}
// Bio (Last.fm bio or summary fallback — matching library page pattern)
const bioEl = document.getElementById('artists-hero-bio');
if (bioEl) {
const bio = info.lastfm_bio || info.bio || '';
if (bio) {
// Strip HTML tags and "Read more on Last.fm" links
let cleanBio = bio.replace(/<a\b[^>]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim();
if (cleanBio) {
bioEl.innerHTML = `<span class="artists-hero-bio-text">${_esc(cleanBio)}</span>
<span class="artists-hero-bio-toggle" onclick="this.parentElement.classList.toggle('expanded');this.textContent=this.parentElement.classList.contains('expanded')?'Show less':'Read more'">Read more</span>`;
bioEl.style.display = '';
} else {
bioEl.style.display = 'none';
}
} else {
bioEl.style.display = 'none';
}
}
// Stats (Last.fm listeners + playcount, with followers fallback)
const statsEl = document.getElementById('artists-hero-stats');
if (statsEl) {
const _fmtNum = (n) => {
if (!n || n <= 0) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
return n.toLocaleString();
};
let stats = '';
if (info.lastfm_listeners) {
stats += `<span class="artists-hero-stat"><strong>${_fmtNum(info.lastfm_listeners)}</strong> listeners</span>`;
}
if (info.lastfm_playcount) {
stats += `<span class="artists-hero-stat"><strong>${_fmtNum(info.lastfm_playcount)}</strong> plays</span>`;
}
if (!stats && info.followers) {
stats += `<span class="artists-hero-stat"><strong>${_fmtNum(info.followers)}</strong> followers</span>`;
}
statsEl.innerHTML = stats;
}
// Also update old hidden elements for any JS that references them
const oldImage = document.getElementById('search-artist-detail-image');
if (oldImage && imageUrl) oldImage.style.backgroundImage = `url('${imageUrl}')`;
const oldName = document.getElementById('search-artist-detail-name');
if (oldName) oldName.textContent = artist.name;
// Initialize watchlist button
initializeArtistDetailWatchlistButton(artist);
}
/**
* Initialize watchlist button for artist detail page
*/
async function initializeArtistDetailWatchlistButton(artist) {
const button = document.getElementById('artist-detail-watchlist-btn');
if (!button) return;
console.log(`🔧 Initializing watchlist button for artist: ${artist.name} (${artist.id})`);
// Store artist info on the button for settings gear access
button.dataset.artistId = artist.id;
button.dataset.artistName = artist.name;
// Reset button state completely
button.disabled = false;
button.classList.remove('watching');
button.style.background = '';
button.style.cursor = '';
// Remove any existing click handlers to prevent duplicates
button.onclick = null;
// Set up new click handler
button.onclick = (event) => toggleArtistDetailWatchlist(event, artist.id, artist.name);
// Check and update current status
await updateArtistDetailWatchlistButton(artist.id, artist.name);
}
/**
* Toggle watchlist status for artist detail page
*/
async function toggleArtistDetailWatchlist(event, artistId, artistName) {
event.preventDefault();
const button = document.getElementById('artist-detail-watchlist-btn');
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
// Show loading state
const originalText = text.textContent;
text.textContent = 'Loading...';
button.disabled = true;
try {
// Check current status
const checkResponse = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const checkData = await checkResponse.json();
if (!checkData.success) {
throw new Error(checkData.error || 'Failed to check watchlist status');
}
const isWatching = checkData.is_watching;
// Toggle watchlist status
const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add';
const payload = isWatching ?
{ artist_id: artistId } :
{ artist_id: artistId, artist_name: artistName };
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update watchlist');
}
// Update button appearance
if (isWatching) {
// Was watching, now removed
icon.textContent = '👁️';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
console.log(`❌ Removed ${artistName} from watchlist`);
} else {
// Was not watching, now added
icon.textContent = '👁️';
text.textContent = 'Remove from Watchlist';
button.classList.add('watching');
console.log(`✅ Added ${artistName} to watchlist`);
}
// Show/hide watchlist settings gear
const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn');
if (settingsBtn) {
if (!isWatching) {
// Just added to watchlist — show gear
settingsBtn.classList.remove('hidden');
settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, artistName);
} else {
// Just removed from watchlist — hide gear
settingsBtn.classList.add('hidden');
settingsBtn.onclick = null;
}
}
// Update dashboard watchlist count
updateWatchlistButtonCount();
// Update any visible artist cards
updateArtistCardWatchlistStatus();
} catch (error) {
console.error('Error toggling watchlist:', error);
text.textContent = originalText;
// Show error feedback
const originalBackground = button.style.background;
button.style.background = 'rgba(255, 59, 48, 0.3)';
setTimeout(() => {
button.style.background = originalBackground;
}, 2000);
} finally {
button.disabled = false;
}
}
/**
* Update artist detail watchlist button status
*/
async function updateArtistDetailWatchlistButton(artistId, artistName) {
const button = document.getElementById('artist-detail-watchlist-btn');
if (!button) {
console.warn('⚠️ Artist detail watchlist button not found');
return;
}
// Use passed name or fall back to stored data attribute
const name = artistName || button.dataset.artistName || '';
try {
console.log(`🔍 Checking watchlist status for artist: ${artistId}`);
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) {
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
console.log(`📊 Watchlist status for ${artistId}: ${data.is_watching ? 'WATCHING' : 'NOT WATCHING'}`);
// Ensure button is enabled
button.disabled = false;
// Show/hide watchlist settings gear
const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn');
if (settingsBtn) {
if (data.is_watching) {
settingsBtn.classList.remove('hidden');
settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, name);
} else {
settingsBtn.classList.add('hidden');
settingsBtn.onclick = null;
}
}
if (data.is_watching) {
icon.textContent = '👁️';
text.textContent = 'Remove from Watchlist';
button.classList.add('watching');
} else {
icon.textContent = '👁️';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
}
} else {
console.error('❌ Failed to check watchlist status:', data.error);
}
} catch (error) {
console.error('❌ Error checking watchlist status:', error);
// Ensure button doesn't get stuck in bad state
button.disabled = false;
}
}
/**
* Show loading state for discography
*/
function showDiscographyLoading() {
const albumsContainer = document.getElementById('album-cards-container');
const singlesContainer = document.getElementById('singles-cards-container');
const loadingHtml = `
<div class="album-card loading">
<div class="album-card-image"></div>
<div class="album-card-content">
<div class="album-card-name">Loading...</div>
<div class="album-card-year">-</div>
<div class="album-card-type">-</div>
</div>
</div>
`.repeat(4);
if (albumsContainer) albumsContainer.innerHTML = loadingHtml;
if (singlesContainer) singlesContainer.innerHTML = loadingHtml;
}
/**
* Show error state for discography
*/
function showDiscographyError(message = 'Failed to load discography') {
const albumsContainer = document.getElementById('album-cards-container');
const singlesContainer = document.getElementById('singles-cards-container');
const errorHtml = `
<div style="grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: rgba(255, 65, 54, 0.8);">
<div style="font-size: 18px; margin-bottom: 8px;">⚠️</div>
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">Failed to load discography</div>
<div style="font-size: 12px; color: rgba(255, 65, 54, 0.6); max-width: 300px; margin: 0 auto;">${escapeHtml(message)}</div>
</div>
`;
if (albumsContainer) albumsContainer.innerHTML = errorHtml;
if (singlesContainer) singlesContainer.innerHTML = errorHtml;
}
/**
* Show loading cards while searching
*/
function showSearchLoadingCards() {
const container = document.getElementById('artists-cards-container');
if (!container) return;
const loadingCardHtml = `
<div class="artist-card loading">
<div class="artist-card-background"></div>
<div class="artist-card-overlay"></div>
<div class="artist-card-content">
<div class="artist-card-name">Loading...</div>
<div class="artist-card-genres">Fetching data...</div>
<div class="artist-card-popularity">
<span class="popularity-icon">⏳</span>
<span>Loading...</span>
</div>
</div>
</div>
`;
// Show 6 loading cards
container.innerHTML = loadingCardHtml.repeat(6);
}
// ===============================
// ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION
// ===============================
/**
* Get the completion status of an album from cached data or DOM
* @param {string} albumId - The album ID
* @param {string} albumType - The album type ('albums' or 'singles')
* @returns {Object|null} - Completion status object or null
*/
function getAlbumCompletionStatus(albumId, albumType) {
try {
// First, check cached completion data
const artistId = artistsPageState.selectedArtist?.id;
if (artistId && artistsPageState.cache.completionData[artistId]) {
const cachedData = artistsPageState.cache.completionData[artistId];
const dataArray = albumType === 'albums' ? cachedData.albums : cachedData.singles;
if (dataArray) {
const completionData = dataArray.find(item => item.album_id === albumId || item.id === albumId);
if (completionData) {
console.log(`📊 Found cached completion data for album ${albumId}:`, completionData);
return completionData;
}
}
}
// Fallback: Check DOM completion overlay
const containerId = albumType === 'albums' ? 'album-cards-container' : 'singles-cards-container';
const container = document.getElementById(containerId);
if (container) {
const albumCard = container.querySelector(`[data-album-id="${albumId}"]`);
if (albumCard) {
const overlay = albumCard.querySelector('.completion-overlay');
if (overlay) {
// Extract status from overlay classes
const classList = Array.from(overlay.classList);
const statusClasses = ['completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error'];
const status = statusClasses.find(cls => classList.includes(cls));
if (status) {
console.log(`📊 Found DOM completion status for album ${albumId}: ${status}`);
return { status, completion_percentage: status === 'completed' ? 100 : 0 };
}
}
}
}
console.warn(`⚠️ No completion status found for album ${albumId}`);
return null;
} catch (error) {
console.error(`❌ Error getting album completion status for ${albumId}:`, error);
return null;
}
}
/**
* Handle album/single/EP click to open download missing tracks modal
*/
async function handleArtistAlbumClick(album, albumType) {
console.log(`🎵 Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`);
if (!artistsPageState.selectedArtist) {
console.error('❌ No selected artist found');
showToast('Error: No artist selected', 'error');
return;
}
showLoadingOverlay('Loading album...');
try {
// Check completion status of the album
const completionStatus = getAlbumCompletionStatus(album.id, albumType);
console.log(`📊 Album completion status: ${completionStatus?.status || 'unknown'} (${completionStatus?.completion_percentage || 0}%)`);
// For Artists page, always use Download Missing Tracks modal to analyze and download
console.log(`🔄 Opening download missing tracks modal for album analysis`);
// Create virtual playlist ID
const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`;
// Check if modal already exists and show it
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`📱 Reopening existing modal for ${album.name}`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
hideLoadingOverlay();
return;
}
}
// Create virtual playlist and open modal
// Note: Don't hide loading overlay here - let the flow continue through to the modal
await createArtistAlbumVirtualPlaylist(album, albumType);
} catch (error) {
hideLoadingOverlay();
console.error('❌ Error handling album click:', error);
showToast(`Error opening download modal: ${error.message}`, 'error');
}
}
/**
* Create virtual playlist for artist album and open download missing tracks modal
*/
async function createArtistAlbumVirtualPlaylist(album, albumType) {
const artist = artistsPageState.selectedArtist;
const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`;
console.log(`🎵 Creating virtual playlist for: ${artist.name} - ${album.name}`);
try {
// Loading overlay already shown by handleArtistAlbumClick
// Fetch album tracks from backend (pass name/artist for Hydrabase support)
const _aat1 = new URLSearchParams({ name: album.name || '', artist: artist.name || '' });
const albumSource = artistsPageState.sourceOverride || album.source || artist.source || artistsPageState.artistDiscography?.source || null;
if (albumSource) {
_aat1.set('source', albumSource);
}
if (artistsPageState.pluginOverride) {
_aat1.set('plugin', artistsPageState.pluginOverride);
}
const response = await fetch(`/api/album/${album.id}/tracks?${_aat1}`);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Spotify not authenticated. Please check your API settings.');
}
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error || `Failed to load album tracks: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found for this album');
}
console.log(`✅ Loaded ${data.tracks.length} tracks for ${data.album.name}`);
// Use album data from API response (has complete data including images array)
const fullAlbumData = data.album;
// Format playlist name with artist and album info
const playlistName = `[${artist.name}] ${fullAlbumData.name}`;
// Open download missing tracks modal with formatted tracks
// Pass false for showLoadingOverlay since we already have one from handleArtistAlbumClick
// Use fullAlbumData from API response instead of album parameter
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, fullAlbumData, artist, false);
// Track this download for artist bubble management
registerArtistDownload(artist, album, virtualPlaylistId, albumType);
} catch (error) {
console.error('❌ Error creating virtual playlist:', error);
showToast(`Failed to load album: ${error.message}`, 'error');
throw error;
}
}
/**
* Open download missing tracks modal specifically for artist albums
* Similar to openDownloadMissingModalForYouTube but for artist albums
*/