From 93f194182976cb760fccc90de00af069ed7b2a0b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:00:52 -0700 Subject: [PATCH] Unify artist detail: route source artists to standalone page, retire inline Artists page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the artist-detail unification. Source artists now land on the same /artist-detail page as library artists (with the source-aware backend endpoint from earlier this session handling the data fetch). The inline Artists page is gone — artists.js deleted, #artists-page HTML block removed, /artists URL aliases to /search. Source-artist callsites re-migrated from selectArtistForDetail to navigateToArtistDetail (search results, global widget, download modal, Discover hero / Your Artists cards / artmap context / genre deep-dive, watchlist artist detail). Visual upgrade to standalone hero: added .artist-detail-hero-bg + .artist-detail-hero-overlay (blurred image bg, dark gradient — same treatment as the inline page). library.js sets the bg image when loading an artist. Library-only UI hidden via CSS for source artists (existing rules from the previous commit cover Enhanced toggle, Status filter, completion bars, enrichment coverage, Top Tracks sidebar, Radio / Enhance buttons). Final 2 helpers (lazyLoadArtistImages used by wishlist-tools, showCompletionError used by completion checker) moved from artists.js into shared-helpers.js. The inline-page candidate set was dropped from _resolveSimilarArtistsTargets. init.js: 'artists' alias added at top of navigateToPage (same pattern as the existing 'downloads' alias). 'case artists:' handler removed from loadPageData. _getPageFromPath now maps artist-detail to library as its parent (matches the existing nav highlight at init.js:2161). tests/test_script_split_integrity.py: artists.js removed from SPLIT_MODULES; KNOWN_CROSS_FILE_DUPES updated to point escapeHtml at shared-helpers.js instead of artists.js. 354/354 tests pass. Net delta: -1700 lines. Stays at 2.39. Once you've verified end-to-end (library artist -> hero looks like inline visual; source artist from Search -> same page, similar artists works, no 404s; /artists URL -> /search), a follow-up commit bumps to 2.40 with the full WHATS_NEW entry that's already prepped. --- tests/test_script_split_integrity.py | 3 +- webui/index.html | 145 +-- webui/static/api-monitor.js | 10 +- webui/static/artists.js | 1584 -------------------------- webui/static/discover.js | 32 +- webui/static/downloads.js | 25 +- webui/static/init.js | 18 +- webui/static/library.js | 11 + webui/static/search.js | 14 +- webui/static/shared-helpers.js | 127 ++- webui/static/style.css | 38 + 11 files changed, 153 insertions(+), 1854 deletions(-) delete mode 100644 webui/static/artists.js diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index d5982c9e..a801f7d1 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -39,7 +39,6 @@ SPLIT_MODULES = [ "downloads.js", "wishlist-tools.js", "sync-services.js", - "artists.js", "api-monitor.js", "library.js", "beatport-ui.js", @@ -57,7 +56,7 @@ NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "work # In a plain - diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index bc86b223..c900d824 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2385,16 +2385,8 @@ async function openWatchlistArtistDetailView(artistId, artistName) { source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes'; } if (discogId) { - // Watchlist discogId is a metadata-source id (Spotify/Deezer/iTunes), - // not a library PK — route through the Artists page inline view. closeWatchlistArtistDetailView(); - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail( - { id: discogId, name: artistName, image_url: artist.image_url || '' }, - { source: source } - ); - }, 200); + navigateToArtistDetail(discogId, artistName, source); } }); diff --git a/webui/static/artists.js b/webui/static/artists.js deleted file mode 100644 index 0bd23d63..00000000 --- a/webui/static/artists.js +++ /dev/null @@ -1,1584 +0,0 @@ -// 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 = ` -
-
🔍
-
No artists found
-
Try a different search term
-
- `; - 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 = ` -
- -
- `; - } - - return ` -
- ${mbIconHTML} -
-
-
-
${escapeHtml(artist.name)}
-
${escapeHtml(genres)}
-
- đŸ”Ĩ - ${popularityText} -
-
-
- - -
-
-
-
- `; -} - -/** - * 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//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 = ` -
-
đŸ’ŋ
-
No albums found
-
- `; - } - } - - // 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 = ` -
-
đŸŽĩ
-
No singles or EPs found
-
- `; - } - } - - // 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 = 'Error'; - 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 ` -
-
-
- Checking... -
-
-
${escapeHtml(album.name)}
-
${year || 'Unknown'}
-
${type}
-
-
- `; -} - -/** - * 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 = ` -
${message || 'Search failed. Please try again.'}
- - `; - 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, '&').replace(//g, '>').replace(/"/g, '"'); - 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 = '🎤'; - // 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 - ? `${fallback}` - : `${fallback}`; - if (url) return `${inner}`; - return `
${inner}
`; - }; - 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 => - `${_esc(g)}` - ).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>/gi, '').replace(/<[^>]+>/g, '').trim(); - if (cleanBio) { - bioEl.innerHTML = `${_esc(cleanBio)} - Read more`; - 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 += `${_fmtNum(info.lastfm_listeners)} listeners`; - } - if (info.lastfm_playcount) { - stats += `${_fmtNum(info.lastfm_playcount)} plays`; - } - if (!stats && info.followers) { - stats += `${_fmtNum(info.followers)} followers`; - } - 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 = ` -
-
-
-
Loading...
-
-
-
-
-
-
- `.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 = ` -
-
âš ī¸
-
Failed to load discography
-
${escapeHtml(message)}
-
- `; - - 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 = ` -
-
-
-
-
Loading...
-
Fetching data...
-
- âŗ - Loading... -
-
-
- `; - - // 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 - */ diff --git a/webui/static/discover.js b/webui/static/discover.js index e0293a60..d322cc81 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -739,14 +739,7 @@ async function checkRecommendedWatchlistStatuses(artists) { async function viewRecommendedArtistDiscography(artistId, artistName) { closeRecommendedArtistsModal(); - - const artist = { id: artistId, name: artistName }; - - // Recommended artists come from the metadata source — route through the - // Artists page's inline view so the source-provided id resolves correctly. - navigateToPage('artists'); - await new Promise(resolve => setTimeout(resolve, 100)); - await selectArtistForDetail(artist); + navigateToArtistDetail(artistId, artistName); } async function checkAllHeroWatchlistStatus() { @@ -828,21 +821,8 @@ async function viewDiscoverHeroDiscography() { return; } - const artist = { - id: artistId, - name: artistName, - image_url: discoverHeroArtists[discoverHeroIndex]?.image_url || '', - genres: discoverHeroArtists[discoverHeroIndex]?.genres || [], - popularity: discoverHeroArtists[discoverHeroIndex]?.popularity || 0 - }; - console.log(`đŸŽĩ Navigating to artist detail for: ${artistName}`); - - // Hero artists are source-provided recommendations — route through the - // Artists page's inline view so the source id resolves correctly. - navigateToPage('artists'); - await new Promise(resolve => setTimeout(resolve, 100)); - await selectArtistForDetail(artist); + navigateToArtistDetail(artistId, artistName); } function showDiscoverHeroEmpty() { @@ -4635,7 +4615,7 @@ function _renderYourArtistCard(artist) { // Navigate to Artists page (name click) — source artist id, needs inline view const navAction = hasId - ? `event.stopPropagation(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artist.active_source_id)}', name:'${escapeForInlineJs(artist.artist_name)}', image_url:'${escapeForInlineJs(img)}'}), 200)` + ? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(artist.active_source_id)}', '${escapeForInlineJs(artist.artist_name)}')` : ''; // Open info modal (card body click) — pass pool ID so we can look up all data @@ -4814,7 +4794,7 @@ async function openYourArtistInfoModal(poolId) { Explore - @@ -6714,7 +6694,7 @@ function _artMapSetupInteraction(canvas) {
Artist Info
-
+
💿 View Discography
@@ -7402,7 +7382,7 @@ async function openGenreDeepDive(genre) { // Always open on Artists page with discography — pass source for correct routing const imgUrl = _esc(a.image_url || ''); const artSource = _esc(a.source || ''); - const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`; + const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetail('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`; const srcClass = (a.source || '').toLowerCase(); return `
diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 86d0ad24..f13bfb2e 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -634,15 +634,7 @@ function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, play if (!artistName) return; // Close the download modal if (playlistId) closeDownloadMissingModal(playlistId); - // The id from a download modal is typically a metadata-source id; route via - // the Artists page inline view so the source-aware discography endpoint runs. - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail( - { id: artistId || artistName, name: artistName, image_url: imageUrl || '' }, - source ? { source: source } : undefined - ); - }, 200); + navigateToArtistDetail(artistId || artistName, artistName, source || null); } async function closeDownloadMissingModal(playlistId) { @@ -5433,19 +5425,8 @@ function _gsSwitchSource(src) { function _gsClickArtist(id, name, isLibrary) { _gsDeactivate(); - if (isLibrary) { - // Library artists: id is a local DB PK — use the standalone artist-detail page. - navigateToArtistDetail(id, name); - } else { - // Source artists: id is a Deezer/Spotify/iTunes id — route to the Artists - // page's inline view which fetches discography from the source. - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail({ id, name, image_url: '' }, { - source: _gsState.activeSource || '', - }); - }, 150); - } + const source = isLibrary ? null : (_gsState.activeSource || null); + navigateToArtistDetail(id, name, source); } async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { diff --git a/webui/static/init.js b/webui/static/init.js index e8e38463..4b8ec35c 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2010,7 +2010,7 @@ function _getPageFromPath() { const basePage = path.split('/')[0]; if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard'; // Context-dependent pages fall back to a sensible parent - if (basePage === 'artist-detail') return 'artists'; + if (basePage === 'artist-detail') return 'library'; if (basePage === 'playlist-explorer') return 'library'; return basePage; } @@ -2115,8 +2115,10 @@ function initializeWatchlist() { } function navigateToPage(pageId, options = {}) { - // Backwards-compat alias — the Search page used to live under id 'downloads'. - if (pageId === 'downloads') pageId = 'search'; + // Backwards-compat aliases — both legacy ids fold into the unified Search page. + // 'downloads' was the Search page's old id; 'artists' was the retired inline + // Artists page, now replaced by clicking artists from the unified Search. + if (pageId === 'downloads' || pageId === 'artists') pageId = 'search'; if (pageId === currentPage) return; @@ -2218,15 +2220,7 @@ async function loadPageData(pageId) { initializeSearchModeToggle(); initializeFilters(); break; - case 'artists': - // Only fully initialize if not already initialized - if (!artistsPageState.isInitialized) { - initializeArtistsPage(); - } else { - // Just restore state if already initialized - restoreArtistsPageState(); - } - break; + // 'artists' page retired — aliased to 'search' at the top of navigateToPage case 'active-downloads': loadActiveDownloadsPage(); break; diff --git a/webui/static/library.js b/webui/static/library.js index 147d52b1..74b3e2a5 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -1063,6 +1063,17 @@ function updateArtistHeaderStats(albumCount, trackCount) { function updateArtistHeroSection(artist, discography) { console.log("đŸ–ŧī¸ Updating artist hero section"); + // Blurred background image (inline-Artists hero treatment) — set whenever + // we have an image_url; falls back to clearing the bg if not. + const heroBg = document.getElementById("artist-detail-hero-bg"); + if (heroBg) { + if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") { + heroBg.style.backgroundImage = `url('${artist.image_url}')`; + } else { + heroBg.style.backgroundImage = ''; + } + } + // Update artist image with detailed debugging const imageElement = document.getElementById("artist-detail-image"); const fallbackElement = document.getElementById("artist-detail-image-fallback"); diff --git a/webui/static/search.js b/webui/static/search.js index fd8b0b37..745b9763 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -336,21 +336,11 @@ function initializeSearchModeToggle() { name: artist.name, meta: 'Artist', badge: sourceBadge, - onClick: async () => { + onClick: () => { const sourceOverride = _activeSearchSource; console.log(`đŸŽĩ Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); hideDropdown(); - - // Source artists are NOT library entries — their id is a Deezer/ - // Spotify/iTunes id, not a library PK. Route to the Artists page's - // inline selectArtistForDetail which fetches discography from the - // source directly, not the library's /api/artist-detail endpoint. - navigateToPage('artists'); - await new Promise(resolve => setTimeout(resolve, 100)); - await selectArtistForDetail(artist, { - source: sourceOverride, - plugin: artist.external_urls?.hydrabase_plugin, - }); + navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); } }) ); diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 598c02f7..312278fb 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -2768,41 +2768,19 @@ function renderEnrichmentCards(enrichment) { // container that live on the artist-detail page. // ---------------------------------------------------------------------------- -// Similar artists lives on two pages (the inline Artists page and the standalone -// artist-detail page), each with its own set of IDs so they don't collide in the -// DOM. This resolver picks the set whose `.page` ancestor is currently active. +// Similar artists section lives on the standalone artist-detail page with the +// 'ad-' prefixed ids. The resolver shape was originally designed for both the +// inline Artists page and the standalone page; the inline page has since been +// retired, so only the standalone candidate remains. function _resolveSimilarArtistsTargets() { - const candidates = [ - // standalone artist-detail page (scoped ids) - { section: 'ad-similar-artists-section', loading: 'ad-similar-artists-loading', error: 'ad-similar-artists-error', bubbles: 'ad-similar-artists-bubbles-container' }, - // legacy inline Artists page (base ids) - { section: 'similar-artists-section', loading: 'similar-artists-loading', error: 'similar-artists-error', bubbles: 'similar-artists-bubbles-container' }, - ]; - // Prefer the set whose parent .page is currently active. - for (const c of candidates) { - const el = document.getElementById(c.section); - if (el && el.closest('.page.active')) { - return { - section: el, - loadingEl: document.getElementById(c.loading), - errorEl: document.getElementById(c.error), - container: document.getElementById(c.bubbles), - }; - } - } - // Fallback: return the first set that exists at all. - for (const c of candidates) { - const el = document.getElementById(c.section); - if (el) { - return { - section: el, - loadingEl: document.getElementById(c.loading), - errorEl: document.getElementById(c.error), - container: document.getElementById(c.bubbles), - }; - } - } - return null; + const sectionEl = document.getElementById('ad-similar-artists-section'); + if (!sectionEl) return null; + return { + section: sectionEl, + loadingEl: document.getElementById('ad-similar-artists-loading'), + errorEl: document.getElementById('ad-similar-artists-error'), + container: document.getElementById('ad-similar-artists-bubbles-container'), + }; } async function loadSimilarArtists(artistName) { @@ -3110,21 +3088,78 @@ function createSimilarArtistBubble(artist) { bubble.appendChild(genres); } - // Add click handler — page-aware destination. From the standalone artist- - // detail page, navigate to the standalone route. From the inline Artists - // page, swap the inline view via selectArtistForDetail. + // Click → navigate to the standalone artist-detail page. Works for both + // library and source artists thanks to the source-aware backend endpoint. bubble.addEventListener('click', () => { console.log(`đŸŽĩ Clicked similar artist: ${artist.name} (ID: ${artist.id})`); - const onStandalone = !!document.querySelector('#artist-detail-page.active'); - if (onStandalone && typeof navigateToArtistDetail === 'function') { - navigateToArtistDetail(artist.id, artist.name, artist.source || null); - } else if (typeof selectArtistForDetail === 'function') { - selectArtistForDetail( - artist, - artist.source ? { source: artist.source, plugin: artist.plugin } : {} - ); - } + navigateToArtistDetail(artist.id, artist.name, artist.source || null); }); return bubble; } + + +// ---------------------------------------------------------------------------- +// Lazy artist-card image loader (used by wishlist-tools.js + the legacy inline +// Artists page search results). Fetches /api/artist//image for each card +// flagged data-needs-image="true" in batches of 5. +// ---------------------------------------------------------------------------- + +async function lazyLoadArtistImages(container) { + if (!container) { + console.error('❌ lazyLoadArtistImages: container is null'); + return; + } + + const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); + if (cardsNeedingImages.length === 0) return; + + 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) return; + try { + const response = await fetch(`/api/artist/${artistId}/image`); + const data = await response.json(); + if (data.success && data.image_url) { + 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) { + bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; + } + } + card.dataset.needsImage = 'false'; + } + } catch (error) { + console.error(`❌ Failed to load image for artist ${artistId}:`, error); + } + })); + } +} + +// Legacy global alias — wishlist-tools.js falls back to window.lazyLoadArtistImages +window.lazyLoadArtistImages = lazyLoadArtistImages; + + +// ---------------------------------------------------------------------------- +// Album-card completion overlay error state (called from checkDiscographyCompletion +// when the API request fails) +// ---------------------------------------------------------------------------- + +function showCompletionError() { + const allOverlays = document.querySelectorAll('.completion-overlay.checking'); + allOverlays.forEach(overlay => { + overlay.classList.remove('checking'); + overlay.classList.add('error'); + overlay.innerHTML = 'Error'; + overlay.title = 'Failed to check completion status'; + }); +} diff --git a/webui/static/style.css b/webui/static/style.css index 6600bcec..9720cd29 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -59413,3 +59413,41 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt body[data-artist-source="source"] #artist-detail-page .section-stats { display: none !important; } + + +/* ========================================================================= + Standalone /artist-detail page hero — blurred-background treatment + ========================================================================= */ + +#artist-detail-page .artist-hero-section { + position: relative; + overflow: hidden; +} + +#artist-detail-page .artist-detail-hero-bg { + position: absolute; + inset: -20px; + background-size: cover; + background-position: center top; + background-repeat: no-repeat; + filter: blur(50px) brightness(0.35) saturate(1.4); + transform: scale(1.3); + z-index: 0; + pointer-events: none; +} + +#artist-detail-page .artist-detail-hero-overlay { + position: absolute; + inset: 0; + background: linear-gradient(180deg, + rgba(8, 8, 8, 0.5) 0%, + rgba(12, 12, 12, 0.7) 60%, + rgba(12, 12, 12, 0.9) 100%); + z-index: 1; + pointer-events: none; +} + +#artist-detail-page .artist-hero-content { + position: relative; + z-index: 2; +}