Speed up library page: split DB query, innerHTML rendering, staggered card animation
This commit is contained in:
parent
8c84189121
commit
21d7e65986
3 changed files with 133 additions and 191 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, '"').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}` });
|
||||
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.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}` });
|
||||
}
|
||||
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 = `<div class="watch-card-icon watched source-card-icon" title="On your watchlist"><span class="watch-icon-emoji">👁️</span><span class="watch-icon-label">Watching</span></div>`;
|
||||
} 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 = `<div class="watch-card-icon source-card-icon" data-unwatched="1" title="Add to Watchlist" style="opacity:0.4"><span class="watch-icon-emoji">👁️</span><span class="watch-icon-label">Watch</span></div>`;
|
||||
}
|
||||
|
||||
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 = `<span class="watch-icon-emoji">${source.fallback}</span><span class="watch-icon-label">${source.fallbackExpanded}</span>`;
|
||||
} 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) => `<div class="source-card-icon" title="${_esc(b.title)}" ${b.url ? `data-url="${_esc(b.url)}"` : ''}>${b.logo ? `<img src="${_esc(b.logo)}" style="width:16px;height:auto;display:block" onerror="this.parentNode.textContent='${b.fb}'">` : `<span style="font-size:9px;font-weight:700">${b.fb}</span>`}</div>`;
|
||||
|
||||
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 = `<div class="card-badge-container">
|
||||
<div class="badge-overflow-column">${watchBadgeHTML}${badges.slice(maxPerColumn).map(badgeIcon).join('')}</div>
|
||||
<div class="badge-primary-column">${badges.slice(0, maxPerColumn).map(badgeIcon).join('')}</div>
|
||||
</div>`;
|
||||
} else {
|
||||
// Single column — service badges + watch badge last
|
||||
serviceBadges.forEach(source => {
|
||||
badgeContainer.appendChild(createBadgeIcon(source));
|
||||
});
|
||||
if (watchBadge) {
|
||||
badgeContainer.appendChild(createBadgeIcon(watchBadge));
|
||||
}
|
||||
badgeContainerHTML = `<div class="card-badge-container">${badges.map(badgeIcon).join('')}${watchBadgeHTML}</div>`;
|
||||
}
|
||||
|
||||
card.appendChild(badgeContainer);
|
||||
}
|
||||
|
||||
// Create image element
|
||||
const imageContainer = document.createElement("div");
|
||||
imageContainer.className = "library-artist-image";
|
||||
// Image
|
||||
const hasImage = artist.image_url && artist.image_url.trim() !== '';
|
||||
const deezerFallback = artist.deezer_id ? `if(!this.dataset.triedDeezer){this.dataset.triedDeezer='true';this.src='https://api.deezer.com/artist/${artist.deezer_id}/image?size=big'}else{this.parentNode.innerHTML='<div class=\\'library-artist-image-fallback\\'>🎵</div>'}` : `this.parentNode.innerHTML='<div class=\\'library-artist-image-fallback\\'>🎵</div>'`;
|
||||
const imageHTML = hasImage
|
||||
? `<div class="library-artist-image"><img src="${_esc(artist.image_url)}" alt="${_esc(artist.name)}" loading="lazy" onerror="${deezerFallback}"></div>`
|
||||
: `<div class="library-artist-image"><div class="library-artist-image-fallback">🎵</div></div>`;
|
||||
|
||||
if (artist.image_url && artist.image_url.trim() !== "") {
|
||||
const img = document.createElement("img");
|
||||
img.src = artist.image_url;
|
||||
img.alt = artist.name;
|
||||
img.loading = 'lazy';
|
||||
img.onerror = () => {
|
||||
console.log(`Failed to load image for ${artist.name}: ${img.src}`);
|
||||
// Try Deezer fallback before emoji
|
||||
if (artist.deezer_id && !img.dataset.triedDeezer) {
|
||||
img.dataset.triedDeezer = 'true';
|
||||
img.src = `https://api.deezer.com/artist/${artist.deezer_id}/image?size=big`;
|
||||
} else {
|
||||
imageContainer.innerHTML = `<div class="library-artist-image-fallback">🎵</div>`;
|
||||
}
|
||||
};
|
||||
img.onload = () => {
|
||||
console.log(`Successfully loaded image for ${artist.name}: ${artist.image_url}`);
|
||||
};
|
||||
imageContainer.appendChild(img);
|
||||
} else {
|
||||
console.log(`No image URL for ${artist.name}: '${artist.image_url}'`);
|
||||
imageContainer.innerHTML = `<div class="library-artist-image-fallback">🎵</div>`;
|
||||
}
|
||||
// Track stats
|
||||
const trackStat = artist.track_count > 0 ? `<span class="library-artist-stat">${artist.track_count} track${artist.track_count !== 1 ? 's' : ''}</span>` : '';
|
||||
|
||||
// Create info section
|
||||
const info = document.createElement("div");
|
||||
info.className = "library-artist-info";
|
||||
|
||||
const name = document.createElement("h3");
|
||||
name.className = "library-artist-name";
|
||||
name.textContent = artist.name;
|
||||
name.title = artist.name; // For tooltip on long names
|
||||
|
||||
const stats = document.createElement("div");
|
||||
stats.className = "library-artist-stats";
|
||||
|
||||
if (artist.track_count > 0) {
|
||||
const trackStat = document.createElement("span");
|
||||
trackStat.className = "library-artist-stat";
|
||||
trackStat.textContent = `${artist.track_count} track${artist.track_count !== 1 ? "s" : ""}`;
|
||||
|
||||
stats.appendChild(trackStat);
|
||||
}
|
||||
|
||||
info.appendChild(name);
|
||||
info.appendChild(stats);
|
||||
|
||||
// Assemble card
|
||||
card.appendChild(imageContainer);
|
||||
card.appendChild(info);
|
||||
|
||||
// Add click handler to navigate to artist detail page
|
||||
card.addEventListener("click", () => {
|
||||
console.log(`🎵 Opening artist detail for: ${artist.name} (ID: ${artist.id})`);
|
||||
navigateToArtistDetail(artist.id, artist.name);
|
||||
});
|
||||
|
||||
return card;
|
||||
return `<div class="library-artist-card" data-artist-id="${_esc(String(artist.id))}" data-artist-name="${_esc(artist.name)}" style="position:relative;animation:cardFadeIn 0.35s cubic-bezier(0.4,0,0.2,1) ${delay}ms both">
|
||||
${badgeContainerHTML}
|
||||
${imageHTML}
|
||||
<div class="library-artist-info">
|
||||
<h3 class="library-artist-name" title="${_esc(artist.name)}">${_esc(artist.name)}</h3>
|
||||
<div class="library-artist-stats">${trackStat}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function updateLibraryPagination(pagination) {
|
||||
|
|
|
|||
|
|
@ -18722,6 +18722,17 @@ body {
|
|||
}
|
||||
|
||||
/* Library Artist Card — full-bleed photo style */
|
||||
@keyframes cardFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.library-artist-card {
|
||||
background: rgba(18, 18, 18, 1);
|
||||
border-radius: 16px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue