diff --git a/database/music_database.py b/database/music_database.py index 278b7c29..ab953c3f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -7530,7 +7530,7 @@ class MusicDatabase: # Empty watchlist, no artists can match where_clause += " AND 0" - # Deduplicate: only count canonical artist rows (one per name+server_source) + # Step 1: Fast count query — no joins, just filter canonical artists count_query = f""" SELECT COUNT(*) as total_count FROM artists a @@ -7541,11 +7541,8 @@ class MusicDatabase: cursor.execute(count_query, params) total_count = cursor.fetchone()['total_count'] - # Get artists with pagination + # Step 2: Get paginated artist rows (no album/track joins — fast) offset = (page - 1) * limit - - # Deduplicate: select canonical row per name, but count albums/tracks - # across ALL same-name artist IDs (fixes duplicate artist display) artists_query = f""" SELECT a.id, @@ -7562,24 +7559,59 @@ class MusicDatabase: a.tidal_id, a.qobuz_id, a.soul_id, - COUNT(DISTINCT al.id) as album_count, - COUNT(DISTINCT t.id) as track_count + a.server_source FROM artists a - LEFT JOIN albums al ON al.artist_id IN ( - SELECT id FROM artists WHERE name = a.name AND server_source = a.server_source - ) - LEFT JOIN tracks t ON al.id = t.album_id WHERE {where_clause} AND a.id = (SELECT MIN(a2.id) FROM artists a2 WHERE a2.name = a.name AND a2.server_source = a.server_source) - GROUP BY a.id, a.name, a.thumb_url, a.genres, a.musicbrainz_id, a.spotify_artist_id, a.itunes_artist_id, a.deezer_id, a.audiodb_id, a.lastfm_url, a.genius_url, a.tidal_id, a.qobuz_id, a.soul_id ORDER BY a.name COLLATE NOCASE LIMIT ? OFFSET ? """ query_params = params + [limit, offset] - cursor.execute(artists_query, query_params) - rows = cursor.fetchall() + artist_rows = cursor.fetchall() + + # Step 3: Batch-fetch album/track counts only for the 75 artists on this page + artist_ids_on_page = [row['id'] for row in artist_rows] + counts_map = {} + if artist_ids_on_page: + # Get all artist IDs that share names with the page artists (for dedup merging) + name_pairs = [(row['name'], row['server_source']) for row in artist_rows] + # Build counts query using artist IDs directly + # Get all artist IDs sharing names with page artists + id_placeholders = ','.join(['?'] * len(artist_ids_on_page)) + cursor.execute(f""" + SELECT id, name, server_source FROM artists + WHERE id IN ({id_placeholders}) + """, artist_ids_on_page) + page_info = cursor.fetchall() + + # Find all related artist IDs (same name+server) for count merging + or_clauses = [] + or_params = [] + for pi in page_info: + or_clauses.append("(ar.name = ? AND ar.server_source = ?)") + or_params.extend([pi['name'], pi['server_source']]) + + cursor.execute(f""" + SELECT + ar.name as artist_name, ar.server_source as artist_source, + COUNT(DISTINCT al.id) as album_count, + COUNT(DISTINCT t.id) as track_count + FROM artists ar + LEFT JOIN albums al ON al.artist_id = ar.id + LEFT JOIN tracks t ON t.album_id = al.id + WHERE {' OR '.join(or_clauses)} + GROUP BY ar.name, ar.server_source + """, or_params) + # Map back to canonical IDs + name_to_canonical = {(pi['name'], pi['server_source']): pi['id'] for pi in page_info} + for crow in cursor.fetchall(): + cid = name_to_canonical.get((crow['artist_name'], crow['artist_source'])) + if cid: + counts_map[cid] = (crow['album_count'], crow['track_count']) + + rows = artist_rows # Convert to artist objects artists = [] @@ -7625,8 +7657,8 @@ class MusicDatabase: 'tidal_id': row['tidal_id'], 'qobuz_id': row['qobuz_id'], 'soul_id': row['soul_id'], - 'album_count': row['album_count'] or 0, - 'track_count': row['track_count'] or 0, + 'album_count': counts_map.get(row['id'], (0, 0))[0], + 'track_count': counts_map.get(row['id'], (0, 0))[1], 'is_watched': bool(is_watched) } artists.append(artist_data) diff --git a/webui/static/script.js b/webui/static/script.js index c8c034c0..b6f6cb36 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -38287,202 +38287,101 @@ function displayLibraryArtists(artists) { const grid = document.getElementById("library-artists-grid"); if (!grid) return; - // Clear existing content - grid.innerHTML = ""; + // Build all cards as HTML string for single DOM write (much faster than createElement loop) + grid.innerHTML = artists.map((artist, i) => buildLibraryArtistCardHTML(artist, i)).join(''); - // Create artist cards - artists.forEach(artist => { - const card = createLibraryArtistCard(artist); - grid.appendChild(card); - }); + // Attach click handlers via event delegation (single listener vs 75+ individual) + grid.onclick = (e) => { + // Ignore clicks on badge icons (they open external links / toggle watchlist) + const badge = e.target.closest('.source-card-icon'); + if (badge) { + e.stopPropagation(); + const url = badge.dataset.url; + if (url) { window.open(url, '_blank'); return; } + // Watchlist toggle + if (badge.classList.contains('watch-card-icon') && badge.dataset.unwatched) { + const card = badge.closest('.library-artist-card'); + if (card) { + const artistId = card.dataset.artistId; + const artistName = card.dataset.artistName; + const artist = artists.find(a => String(a.id) === artistId); + if (artist) toggleLibraryCardWatchlist(badge, artist); + } + } + return; + } + const card = e.target.closest('.library-artist-card'); + if (card) { + navigateToArtistDetail(card.dataset.artistId, card.dataset.artistName); + } + }; } -function createLibraryArtistCard(artist) { - const card = document.createElement("div"); - card.className = "library-artist-card"; - card.setAttribute("data-artist-id", artist.id); - // Add relative positioning for icon and smooth transition - card.style.position = 'relative'; - card.style.transition = 'transform 0.2s, box-shadow 0.2s'; +function buildLibraryArtistCardHTML(artist, index) { + const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + const delay = Math.min(index * 20, 600); // Cap at 600ms so last cards don't wait too long - // Add source badges stacked on top-right - const badgeSources = []; - if (artist.spotify_artist_id) { - badgeSources.push({ cls: 'spotify-card-icon', logo: SPOTIFY_LOGO_URL, fallback: 'SP', title: 'View on Spotify', url: `https://open.spotify.com/artist/${artist.spotify_artist_id}` }); - } - if (artist.musicbrainz_id) { - badgeSources.push({ cls: 'mb-card-icon', logo: MUSICBRAINZ_LOGO_URL, fallback: 'MB', title: 'View on MusicBrainz', url: `https://musicbrainz.org/artist/${artist.musicbrainz_id}` }); - } - if (artist.deezer_id) { - badgeSources.push({ cls: 'deezer-card-icon', logo: DEEZER_LOGO_URL, fallback: 'Dz', title: 'View on Deezer', url: `https://www.deezer.com/artist/${artist.deezer_id}` }); - } + // Build badge icons + const badges = []; + if (artist.spotify_artist_id) badges.push({ logo: SPOTIFY_LOGO_URL, fb: 'SP', title: 'Spotify', url: `https://open.spotify.com/artist/${artist.spotify_artist_id}` }); + if (artist.musicbrainz_id) badges.push({ logo: MUSICBRAINZ_LOGO_URL, fb: 'MB', title: 'MusicBrainz', url: `https://musicbrainz.org/artist/${artist.musicbrainz_id}` }); + if (artist.deezer_id) badges.push({ logo: DEEZER_LOGO_URL, fb: 'Dz', title: 'Deezer', url: `https://www.deezer.com/artist/${artist.deezer_id}` }); if (artist.audiodb_id) { - const adbSlug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : ''; - badgeSources.push({ cls: 'audiodb-card-icon', logo: getAudioDBLogoURL(), fallback: 'ADB', title: 'View on TheAudioDB', url: `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${adbSlug}` }); - } - if (artist.itunes_artist_id) { - badgeSources.push({ cls: 'itunes-card-icon', logo: ITUNES_LOGO_URL, fallback: 'IT', title: 'View on Apple Music', url: `https://music.apple.com/artist/${artist.itunes_artist_id}` }); - } - if (artist.lastfm_url) { - badgeSources.push({ cls: 'lastfm-card-icon', logo: LASTFM_LOGO_URL, fallback: 'LFM', title: 'View on Last.fm', url: artist.lastfm_url }); - } - if (artist.genius_url) { - badgeSources.push({ cls: 'genius-card-icon', logo: GENIUS_LOGO_URL, fallback: 'GEN', title: 'View on Genius', url: artist.genius_url }); - } - if (artist.tidal_id) { - badgeSources.push({ cls: 'tidal-card-icon', logo: TIDAL_LOGO_URL, fallback: 'TD', title: 'View on Tidal', url: `https://tidal.com/browse/artist/${artist.tidal_id}` }); + const slug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : ''; + badges.push({ logo: typeof getAudioDBLogoURL === 'function' ? getAudioDBLogoURL() : '', fb: 'ADB', title: 'AudioDB', url: `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${slug}` }); } - if (artist.qobuz_id) { - badgeSources.push({ cls: 'qobuz-card-icon', logo: QOBUZ_LOGO_URL, fallback: 'Qz', title: 'View on Qobuz', url: `https://www.qobuz.com/artist/${artist.qobuz_id}` }); - } - if (artist.soul_id && !artist.soul_id.startsWith('soul_unnamed_')) { - badgeSources.push({ cls: 'soulid-card-icon', logo: '/static/trans2.png', fallback: 'SS', title: `SoulID: ${artist.soul_id}`, url: null }); - } - // Add watchlist indicator — only if artist has a usable ID for the active source + if (artist.itunes_artist_id) badges.push({ logo: ITUNES_LOGO_URL, fb: 'IT', title: 'Apple Music', url: `https://music.apple.com/artist/${artist.itunes_artist_id}` }); + if (artist.lastfm_url) badges.push({ logo: LASTFM_LOGO_URL, fb: 'LFM', title: 'Last.fm', url: artist.lastfm_url }); + if (artist.genius_url) badges.push({ logo: GENIUS_LOGO_URL, fb: 'GEN', title: 'Genius', url: artist.genius_url }); + if (artist.tidal_id) badges.push({ logo: TIDAL_LOGO_URL, fb: 'TD', title: 'Tidal', url: `https://tidal.com/browse/artist/${artist.tidal_id}` }); + if (artist.qobuz_id) badges.push({ logo: QOBUZ_LOGO_URL, fb: 'Qz', title: 'Qobuz', url: `https://www.qobuz.com/artist/${artist.qobuz_id}` }); + if (artist.soul_id && !artist.soul_id.startsWith('soul_unnamed_')) badges.push({ logo: '/static/trans2.png', fb: 'SS', title: `SoulID: ${artist.soul_id}`, url: null }); + + // Watchlist badge const hasActiveSourceId = currentMusicSourceName === 'Apple Music' ? (artist.itunes_artist_id || artist.spotify_artist_id) : (artist.spotify_artist_id || artist.itunes_artist_id); + let watchBadgeHTML = ''; if (artist.is_watched) { - badgeSources.push({ cls: 'watch-card-icon watched', logo: null, fallback: '👁️', fallbackExpanded: 'Watching', title: 'On your watchlist', url: null, isWatch: true }); + watchBadgeHTML = `
`; } else if (hasActiveSourceId) { - badgeSources.push({ cls: 'watch-card-icon', logo: null, fallback: '👁️', fallbackExpanded: 'Watch', title: 'Add to Watchlist', url: null, isWatch: true, unwatched: true }); + watchBadgeHTML = ``; } - if (badgeSources.length > 0) { - const badgeContainer = document.createElement('div'); - badgeContainer.className = 'card-badge-container'; - - // Separate service badges from watch badge - const serviceBadges = badgeSources.filter(s => !s.isWatch); - const watchBadge = badgeSources.find(s => s.isWatch); - const maxPerColumn = 6; - const needsOverflow = serviceBadges.length > maxPerColumn; - - // Helper to create a badge icon element - const createBadgeIcon = (source) => { - const icon = document.createElement('div'); - icon.className = `${source.cls} source-card-icon`; - icon.title = source.title; - if (source.logo) { - const img = document.createElement('img'); - img.src = source.logo; - img.style.cssText = 'width: 16px; height: auto; display: block;'; - img.onerror = () => { icon.textContent = source.fallback; }; - icon.appendChild(img); - } else if (source.fallbackExpanded) { - icon.innerHTML = ``; - } else { - icon.textContent = source.fallback; - } - if (source.isWatch && source.unwatched) { - icon.style.opacity = '0.4'; - icon.onclick = (e) => { - e.stopPropagation(); - toggleLibraryCardWatchlist(icon, artist); - }; - } else if (source.url) { - icon.onclick = (e) => { - e.stopPropagation(); - window.open(source.url, '_blank'); - }; - } - return icon; - }; + const maxPerColumn = 6; + const needsOverflow = badges.length > maxPerColumn; + const badgeIcon = (b) => ``; + let badgeContainerHTML = ''; + if (badges.length > 0 || watchBadgeHTML) { if (needsOverflow) { - // Overflow column (left) — watch badge first, then extra service badges - const overflowCol = document.createElement('div'); - overflowCol.className = 'badge-overflow-column'; - if (watchBadge) { - overflowCol.appendChild(createBadgeIcon(watchBadge)); - } - serviceBadges.slice(maxPerColumn).forEach(source => { - overflowCol.appendChild(createBadgeIcon(source)); - }); - badgeContainer.appendChild(overflowCol); - - // Primary column (right) — first 6 service badges - const primaryCol = document.createElement('div'); - primaryCol.className = 'badge-primary-column'; - serviceBadges.slice(0, maxPerColumn).forEach(source => { - primaryCol.appendChild(createBadgeIcon(source)); - }); - badgeContainer.appendChild(primaryCol); + badgeContainerHTML = `