Add Similar Artists to standalone /artist-detail page, hide library-only UI for source artists

First increment of the artist-detail unification redesign. Delivers
the two most-visible missing pieces for source artists without touching
the hero layout — that's a later commit.

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

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

artists.js shrinks from 1903 -> 1584 lines (similar-artists block
extracted). shared-helpers.js grows correspondingly. 357/357 tests
still pass. No version bump — this is still 2.39 pending.
This commit is contained in:
Broque Thomas 2026-04-22 16:19:25 -07:00
parent 18146098a7
commit 6a76405444
5 changed files with 432 additions and 319 deletions

View file

@ -2667,6 +2667,27 @@
</div>
</div>
<!-- Similar Artists Section (works for both library and source artists).
Uses its own scoped IDs because the inline Artists page has a section
with the same base IDs and both elements live in the DOM at once. -->
<div class="similar-artists-section" id="ad-similar-artists-section">
<div class="similar-artists-header">
<h3 class="similar-artists-title">Similar Artists</h3>
<p class="similar-artists-subtitle">Discover artists with a similar sound</p>
</div>
<div class="similar-artists-loading hidden" id="ad-similar-artists-loading">
<div class="loading-spinner-small"></div>
<span>Finding similar artists...</span>
</div>
<div class="similar-artists-error hidden" id="ad-similar-artists-error">
<span class="error-icon">⚠️</span>
<span class="error-text">Unable to load similar artists</span>
</div>
<div class="similar-artists-bubbles-container" id="ad-similar-artists-bubbles-container">
<!-- Artist bubble cards will be populated here -->
</div>
</div>
<!-- Enhanced Library Management View -->
<div class="enhanced-view-container hidden" id="enhanced-view-container">
<!-- Populated dynamically by renderEnhancedView() -->

View file

@ -742,325 +742,6 @@ function displayArtistDiscography(discography) {
/**
* Load similar artists from MusicMap
*/
async function loadSimilarArtists(artistName) {
if (!artistName) {
console.warn('⚠️ No artist name provided for similar artists');
return;
}
console.log(`🔍 Loading similar artists for: ${artistName}`);
// Get DOM elements
const section = document.getElementById('similar-artists-section');
const loadingEl = document.getElementById('similar-artists-loading');
const errorEl = document.getElementById('similar-artists-error');
const container = document.getElementById('similar-artists-bubbles-container');
if (!section || !loadingEl || !errorEl || !container) {
console.warn('⚠️ Similar artists section elements not found');
return;
}
// Show loading state
loadingEl.classList.remove('hidden');
errorEl.classList.add('hidden');
container.innerHTML = '';
section.style.display = 'block';
try {
// Create new abort controller for this similar artists stream
similarArtistsController = new AbortController();
// Use streaming endpoint for real-time bubble creation
const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`;
console.log(`📡 Streaming from: ${url}`);
const response = await fetch(url, {
signal: similarArtistsController.signal
});
if (!response.ok) {
throw new Error(`Failed to fetch similar artists: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let artistCount = 0;
// Read the stream
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('✅ Stream complete');
break;
}
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete messages (separated by \n\n)
const messages = buffer.split('\n\n');
buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
if (!message.trim() || !message.startsWith('data: ')) continue;
try {
const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix
if (jsonData.error) {
throw new Error(jsonData.error);
}
if (jsonData.artist) {
// Hide loading on first artist
if (artistCount === 0) {
loadingEl.classList.add('hidden');
}
// Create and append bubble immediately
const bubble = createSimilarArtistBubble(jsonData.artist);
container.appendChild(bubble);
artistCount++;
console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`);
}
if (jsonData.complete) {
console.log(`🎉 Streaming complete: ${jsonData.total} artists`);
if (artistCount === 0) {
loadingEl.classList.add('hidden');
container.innerHTML = `
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.5);">
<div style="font-size: 18px; margin-bottom: 8px;">🎵</div>
<div style="font-size: 14px;">No similar artists found</div>
</div>
`;
} else {
// Lazy load images for similar artists that don't have them
lazyLoadSimilarArtistImages(container);
}
}
} catch (parseError) {
console.error('❌ Error parsing stream message:', parseError);
}
}
}
// Clear the controller when done
similarArtistsController = null;
} catch (error) {
// Don't show error if it was aborted (user navigated away)
if (error.name === 'AbortError') {
console.log('⏹️ Similar artists stream aborted (user navigated to new artist)');
loadingEl.classList.add('hidden');
return;
}
console.error('❌ Error loading similar artists:', error);
// Hide loading, show error
loadingEl.classList.add('hidden');
errorEl.classList.remove('hidden');
// Also show error message in container
container.innerHTML = `
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(239, 68, 68, 0.7);">
<div style="font-size: 18px; margin-bottom: 8px;"></div>
<div style="font-size: 14px;">${error.message}</div>
</div>
`;
} finally {
// Always clear the controller
similarArtistsController = null;
}
}
/**
* Lazy load images for similar artist bubbles that don't have images
*/
async function lazyLoadSimilarArtistImages(container) {
if (!container) return;
const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]');
if (bubblesNeedingImages.length === 0) {
console.log('✅ All similar artist bubbles have images');
return;
}
console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`);
// Load images in parallel batches
const batchSize = 5;
const bubbles = Array.from(bubblesNeedingImages);
for (let i = 0; i < bubbles.length; i += batchSize) {
const batch = bubbles.slice(i, i + batchSize);
await Promise.all(batch.map(async (bubble) => {
const artistId = bubble.getAttribute('data-artist-id');
const artistSource = bubble.getAttribute('data-artist-source') || '';
const artistPlugin = bubble.getAttribute('data-artist-plugin') || '';
if (!artistId) return;
try {
const params = new URLSearchParams();
if (artistSource) params.set('source', artistSource);
if (artistPlugin) params.set('plugin', artistPlugin);
const imageUrl = params.toString()
? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}`
: `/api/artist/${encodeURIComponent(artistId)}/image`;
const response = await fetch(imageUrl);
const data = await response.json();
if (data.success && data.image_url) {
const imageContainer = bubble.querySelector('.similar-artist-bubble-image');
if (imageContainer) {
const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist';
imageContainer.innerHTML = `<img src="${data.image_url}" alt="${artistName}">`;
bubble.setAttribute('data-needs-image', 'false');
console.log(`✅ Loaded image for similar artist ${artistId}`);
}
}
} catch (error) {
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
}
}));
}
console.log('✅ Finished lazy loading similar artist images');
}
/**
* Display similar artist bubble cards progressively (one at a time with delay)
*/
function displaySimilarArtistsProgressively(artists) {
const container = document.getElementById('similar-artists-bubbles-container');
if (!container) {
console.warn('⚠️ Similar artists container not found');
return;
}
// Clear container
container.innerHTML = '';
// Add each bubble with a delay to simulate progressive loading
artists.forEach((artist, index) => {
setTimeout(() => {
const bubble = createSimilarArtistBubble(artist);
container.appendChild(bubble);
}, index * 100); // 100ms delay between each bubble
});
console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`);
}
/**
* Display similar artist bubble cards (all at once - legacy)
*/
function displaySimilarArtists(artists) {
const container = document.getElementById('similar-artists-bubbles-container');
if (!container) {
console.warn('⚠️ Similar artists container not found');
return;
}
// Clear container
container.innerHTML = '';
// Create bubble cards with staggered animation
artists.forEach((artist, index) => {
const bubble = createSimilarArtistBubble(artist);
// Add staggered animation delay (50ms per bubble)
bubble.style.animationDelay = `${index * 0.05}s`;
container.appendChild(bubble);
});
console.log(`✅ Displayed ${artists.length} similar artist bubbles`);
}
/**
* Create a similar artist bubble card element
*/
function createSimilarArtistBubble(artist) {
// Create bubble container
const bubble = document.createElement('div');
bubble.className = 'similar-artist-bubble';
bubble.setAttribute('data-artist-id', artist.id);
bubble.setAttribute('data-artist-source', artist.source || '');
if (artist.plugin) {
bubble.setAttribute('data-artist-plugin', artist.plugin);
}
// Track if image needs lazy loading
const hasImage = artist.image_url && artist.image_url.trim() !== '';
bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true');
// Create image container
const imageContainer = document.createElement('div');
imageContainer.className = 'similar-artist-bubble-image';
if (hasImage) {
const img = document.createElement('img');
img.src = artist.image_url;
img.alt = artist.name;
// Handle image load error
img.onerror = () => {
console.log(`Failed to load image for ${artist.name}`);
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
bubble.setAttribute('data-needs-image', 'true');
};
imageContainer.appendChild(img);
} else {
// No image - show fallback (will be lazy loaded)
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
}
// Create name element
const name = document.createElement('div');
name.className = 'similar-artist-bubble-name';
name.textContent = artist.name;
name.title = artist.name; // Tooltip for full name
// Optional: Create genres element (hidden by default in CSS)
const genres = document.createElement('div');
genres.className = 'similar-artist-bubble-genres';
if (artist.genres && artist.genres.length > 0) {
genres.textContent = artist.genres.slice(0, 2).join(', ');
}
// Assemble bubble
bubble.appendChild(imageContainer);
bubble.appendChild(name);
if (artist.genres && artist.genres.length > 0) {
bubble.appendChild(genres);
}
// Add click handler to navigate to artist detail page
bubble.addEventListener('click', () => {
console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`);
// Navigate to this artist's detail page (same as clicking from search results)
selectArtistForDetail(
artist,
artist.source ? { source: artist.source, plugin: artist.plugin } : {}
);
});
return bubble;
}
/**
* Restore cached completion data without re-scanning the database

View file

@ -949,6 +949,11 @@ function populateArtistDetailPage(data) {
console.log(`📀 EPs:`, discography.eps);
console.log(`📀 Singles:`, discography.singles);
// Tag the body so CSS can hide library-only UI for source artists (e.g.
// the Enhanced view toggle, the Status filter, completion bars). Set
// BEFORE rendering so any layout-dependent code sees the right state.
document.body.dataset.artistSource = (artist && artist.server_source) ? 'library' : 'source';
// Update hero section with image, name, and stats
updateArtistHeroSection(artist, discography);
@ -966,6 +971,13 @@ function populateArtistDetailPage(data) {
if (libraryWatchlistBtn && data.spotify_artist && data.spotify_artist.spotify_artist_id) {
initializeLibraryWatchlistButton(data.spotify_artist.spotify_artist_id, data.spotify_artist.spotify_artist_name);
}
// Load Similar Artists section (works for both library + source artists via
// MusicMap name lookup). Fire-and-forget — the function handles its own
// loading state and errors.
if (artist && artist.name && typeof loadSimilarArtists === 'function') {
loadSimilarArtists(artist.name);
}
}
function updateArtistDetailImage(imageUrl, artistName) {

View file

@ -2759,3 +2759,366 @@ function renderEnrichmentCards(enrichment) {
}
// ===============================
// ----------------------------------------------------------------------------
// Similar Artists — fetch + render via MusicMap. Source-agnostic (artist name
// based), works for both library and metadata-source artists. Targets DOM IDs
// #similar-artists-loading, #similar-artists-error, #similar-artists-bubbles-
// 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.
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;
}
async function loadSimilarArtists(artistName) {
if (!artistName) {
console.warn('⚠️ No artist name provided for similar artists');
return;
}
console.log(`🔍 Loading similar artists for: ${artistName}`);
const targets = _resolveSimilarArtistsTargets();
if (!targets) {
console.warn('⚠️ Similar artists section elements not found on any active page');
return;
}
const { section, loadingEl, errorEl, container } = targets;
// Show loading state
loadingEl.classList.remove('hidden');
errorEl.classList.add('hidden');
container.innerHTML = '';
section.style.display = 'block';
try {
// Create new abort controller for this similar artists stream
similarArtistsController = new AbortController();
// Use streaming endpoint for real-time bubble creation
const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`;
console.log(`📡 Streaming from: ${url}`);
const response = await fetch(url, {
signal: similarArtistsController.signal
});
if (!response.ok) {
throw new Error(`Failed to fetch similar artists: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let artistCount = 0;
// Read the stream
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('✅ Stream complete');
break;
}
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete messages (separated by \n\n)
const messages = buffer.split('\n\n');
buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
if (!message.trim() || !message.startsWith('data: ')) continue;
try {
const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix
if (jsonData.error) {
throw new Error(jsonData.error);
}
if (jsonData.artist) {
// Hide loading on first artist
if (artistCount === 0) {
loadingEl.classList.add('hidden');
}
// Create and append bubble immediately
const bubble = createSimilarArtistBubble(jsonData.artist);
container.appendChild(bubble);
artistCount++;
console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`);
}
if (jsonData.complete) {
console.log(`🎉 Streaming complete: ${jsonData.total} artists`);
if (artistCount === 0) {
loadingEl.classList.add('hidden');
container.innerHTML = `
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.5);">
<div style="font-size: 18px; margin-bottom: 8px;">🎵</div>
<div style="font-size: 14px;">No similar artists found</div>
</div>
`;
} else {
// Lazy load images for similar artists that don't have them
lazyLoadSimilarArtistImages(container);
}
}
} catch (parseError) {
console.error('❌ Error parsing stream message:', parseError);
}
}
}
// Clear the controller when done
similarArtistsController = null;
} catch (error) {
// Don't show error if it was aborted (user navigated away)
if (error.name === 'AbortError') {
console.log('⏹️ Similar artists stream aborted (user navigated to new artist)');
loadingEl.classList.add('hidden');
return;
}
console.error('❌ Error loading similar artists:', error);
// Hide loading, show error
loadingEl.classList.add('hidden');
errorEl.classList.remove('hidden');
// Also show error message in container
container.innerHTML = `
<div style="width: 100%; text-align: center; padding: 40px 20px; color: rgba(239, 68, 68, 0.7);">
<div style="font-size: 18px; margin-bottom: 8px;"></div>
<div style="font-size: 14px;">${error.message}</div>
</div>
`;
} finally {
// Always clear the controller
similarArtistsController = null;
}
}
/**
* Lazy load images for similar artist bubbles that don't have images
*/
async function lazyLoadSimilarArtistImages(container) {
if (!container) return;
const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]');
if (bubblesNeedingImages.length === 0) {
console.log('✅ All similar artist bubbles have images');
return;
}
console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`);
// Load images in parallel batches
const batchSize = 5;
const bubbles = Array.from(bubblesNeedingImages);
for (let i = 0; i < bubbles.length; i += batchSize) {
const batch = bubbles.slice(i, i + batchSize);
await Promise.all(batch.map(async (bubble) => {
const artistId = bubble.getAttribute('data-artist-id');
const artistSource = bubble.getAttribute('data-artist-source') || '';
const artistPlugin = bubble.getAttribute('data-artist-plugin') || '';
if (!artistId) return;
try {
const params = new URLSearchParams();
if (artistSource) params.set('source', artistSource);
if (artistPlugin) params.set('plugin', artistPlugin);
const imageUrl = params.toString()
? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}`
: `/api/artist/${encodeURIComponent(artistId)}/image`;
const response = await fetch(imageUrl);
const data = await response.json();
if (data.success && data.image_url) {
const imageContainer = bubble.querySelector('.similar-artist-bubble-image');
if (imageContainer) {
const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist';
imageContainer.innerHTML = `<img src="${data.image_url}" alt="${artistName}">`;
bubble.setAttribute('data-needs-image', 'false');
console.log(`✅ Loaded image for similar artist ${artistId}`);
}
}
} catch (error) {
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
}
}));
}
console.log('✅ Finished lazy loading similar artist images');
}
/**
* Display similar artist bubble cards progressively (one at a time with delay)
*/
function displaySimilarArtistsProgressively(artists) {
const targets = _resolveSimilarArtistsTargets();
const container = targets && targets.container;
if (!container) {
console.warn('⚠️ Similar artists container not found');
return;
}
// Clear container
container.innerHTML = '';
// Add each bubble with a delay to simulate progressive loading
artists.forEach((artist, index) => {
setTimeout(() => {
const bubble = createSimilarArtistBubble(artist);
container.appendChild(bubble);
}, index * 100); // 100ms delay between each bubble
});
console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`);
}
/**
* Display similar artist bubble cards (all at once - legacy)
*/
function displaySimilarArtists(artists) {
const targets = _resolveSimilarArtistsTargets();
const container = targets && targets.container;
if (!container) {
console.warn('⚠️ Similar artists container not found');
return;
}
// Clear container
container.innerHTML = '';
// Create bubble cards with staggered animation
artists.forEach((artist, index) => {
const bubble = createSimilarArtistBubble(artist);
// Add staggered animation delay (50ms per bubble)
bubble.style.animationDelay = `${index * 0.05}s`;
container.appendChild(bubble);
});
console.log(`✅ Displayed ${artists.length} similar artist bubbles`);
}
/**
* Create a similar artist bubble card element
*/
function createSimilarArtistBubble(artist) {
// Create bubble container
const bubble = document.createElement('div');
bubble.className = 'similar-artist-bubble';
bubble.setAttribute('data-artist-id', artist.id);
bubble.setAttribute('data-artist-source', artist.source || '');
if (artist.plugin) {
bubble.setAttribute('data-artist-plugin', artist.plugin);
}
// Track if image needs lazy loading
const hasImage = artist.image_url && artist.image_url.trim() !== '';
bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true');
// Create image container
const imageContainer = document.createElement('div');
imageContainer.className = 'similar-artist-bubble-image';
if (hasImage) {
const img = document.createElement('img');
img.src = artist.image_url;
img.alt = artist.name;
// Handle image load error
img.onerror = () => {
console.log(`Failed to load image for ${artist.name}`);
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
bubble.setAttribute('data-needs-image', 'true');
};
imageContainer.appendChild(img);
} else {
// No image - show fallback (will be lazy loaded)
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
}
// Create name element
const name = document.createElement('div');
name.className = 'similar-artist-bubble-name';
name.textContent = artist.name;
name.title = artist.name; // Tooltip for full name
// Optional: Create genres element (hidden by default in CSS)
const genres = document.createElement('div');
genres.className = 'similar-artist-bubble-genres';
if (artist.genres && artist.genres.length > 0) {
genres.textContent = artist.genres.slice(0, 2).join(', ');
}
// Assemble bubble
bubble.appendChild(imageContainer);
bubble.appendChild(name);
if (artist.genres && artist.genres.length > 0) {
bubble.appendChild(genres);
}
// Add click handler to navigate to artist detail page
bubble.addEventListener('click', () => {
console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`);
// Navigate to this artist's detail page (same as clicking from search results)
selectArtistForDetail(
artist,
artist.source ? { source: artist.source, plugin: artist.plugin } : {}
);
});
return bubble;
}

View file

@ -59377,3 +59377,39 @@ body.reduce-effects *::after {
font-size: 18px;
}
}
/* =========================================================================
Source-artist visibility rules
=========================================================================
When the artist-detail page is rendering a source artist (Spotify / Deezer /
iTunes / etc. not in the local library), hide library-only UI. The body
data-artist-source attribute is set by library.js populateArtistDetailPage
before rendering.
*/
/* Library-only: status filter (owned/missing only makes sense when you own things) */
body[data-artist-source="source"] #artist-detail-page .filter-group:has(.filter-label:only-child),
body[data-artist-source="source"] #artist-detail-page .discography-filter-btn[data-filter="ownership"] {
display: none !important;
}
/* Library-only: Enhanced view toggle + container (per-track editing requires owned tracks) */
body[data-artist-source="source"] #artist-detail-page .enhanced-view-toggle-btn,
body[data-artist-source="source"] #artist-detail-page #enhanced-view-container {
display: none !important;
}
/* Library-only: completion bars, enrichment coverage, Top Tracks sidebar, Radio / Enhance Quality buttons */
body[data-artist-source="source"] #artist-detail-page .collection-overview,
body[data-artist-source="source"] #artist-detail-page .artist-enrichment-coverage,
body[data-artist-source="source"] #artist-detail-page #artist-hero-sidebar,
body[data-artist-source="source"] #artist-detail-page #library-artist-radio-btn,
body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-btn {
display: none !important;
}
/* Library-only: section-stats "0 owned / 0 missing" counts don't apply */
body[data-artist-source="source"] #artist-detail-page .section-stats {
display: none !important;
}