diff --git a/web_server.py b/web_server.py index 79cb3fe9..68ed5ebe 100644 --- a/web_server.py +++ b/web_server.py @@ -8554,10 +8554,47 @@ def get_artist_detail(artist_id): # Fall back to our database categorization merged_discography = owned_releases + # Compute per-artist track enrichment coverage + enrichment_coverage = {} + try: + with database._get_connection() as conn: + cursor = conn.cursor() + artist_name = artist_info['name'] + server_source = artist_info.get('server_source', '') + cursor.execute(""" + SELECT COUNT(*) FROM tracks t + JOIN albums al ON al.id = t.album_id + JOIN artists ar ON ar.id = al.artist_id + WHERE ar.name = ? AND ar.server_source = ? + """, (artist_name, server_source)) + total = (cursor.fetchone() or [0])[0] + if total > 0: + for svc, col in [('spotify', 'spotify_track_id'), ('musicbrainz', 'musicbrainz_recording_id'), + ('deezer', 'deezer_id'), ('lastfm', 'lastfm_url'), + ('itunes', 'itunes_track_id'), ('audiodb', 'audiodb_id'), + ('genius', 'genius_id'), ('tidal', 'tidal_id'), + ('qobuz', 'qobuz_id')]: + try: + cursor.execute(f""" + SELECT COUNT(*) FROM tracks t + JOIN albums al ON al.id = t.album_id + JOIN artists ar ON ar.id = al.artist_id + WHERE ar.name = ? AND ar.server_source = ? + AND t.{col} IS NOT NULL AND t.{col} != '' + """, (artist_name, server_source)) + matched = (cursor.fetchone() or [0])[0] + enrichment_coverage[svc] = round(matched / total * 100, 1) + except Exception: + enrichment_coverage[svc] = 0 + enrichment_coverage['total_tracks'] = total + except Exception: + pass + response_data = { "success": True, "artist": artist_info, - "discography": merged_discography + "discography": merged_discography, + "enrichment_coverage": enrichment_coverage } # Add Spotify artist data if available diff --git a/webui/index.html b/webui/index.html index 2518dfe7..4e9e11d1 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2581,6 +2581,9 @@ 0/0 + + + diff --git a/webui/static/script.js b/webui/static/script.js index 94aa8825..c8c034c0 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -38817,6 +38817,9 @@ async function loadArtistDetailData(artistId, artistName) { // Update header with artist name and MusicBrainz link LAST to avoid overwrite updateArtistDetailPageHeaderWithData(data.artist); + // Render per-artist enrichment coverage + renderArtistEnrichmentCoverage(data.enrichment_coverage); + // Start streaming ownership checks if we have Spotify discography with checking state if (data.discography && data.discography.albums) { const hasChecking = [...(data.discography.albums || []), ...(data.discography.eps || []), ...(data.discography.singles || [])] @@ -38894,6 +38897,54 @@ function updateArtistDetailPageHeaderWithData(artist) { } } +function renderArtistEnrichmentCoverage(enrichment) { + const el = document.getElementById('artist-enrichment-coverage'); + if (!el) return; + + if (!enrichment || !enrichment.total_tracks) { + el.style.display = 'none'; + return; + } + + const services = [ + { name: 'Spotify', key: 'spotify', color: '#1db954' }, + { name: 'MusicBrainz', key: 'musicbrainz', color: '#ba55d3' }, + { name: 'Deezer', key: 'deezer', color: '#a238ff' }, + { name: 'Last.fm', key: 'lastfm', color: '#d51007' }, + { name: 'iTunes', key: 'itunes', color: '#fc3c44' }, + { name: 'AudioDB', key: 'audiodb', color: '#1a9fff' }, + { name: 'Genius', key: 'genius', color: '#ffff64' }, + { name: 'Tidal', key: 'tidal', color: '#00ffff' }, + { name: 'Qobuz', key: 'qobuz', color: '#4285f4' }, + ]; + + const r = 20, circ = 2 * Math.PI * r; + + el.style.display = ''; + el.innerHTML = ` +
Enrichment Coverage
+
+ ${services.map((s, i) => { + const pct = enrichment[s.key] || 0; + const offset = circ - (circ * pct / 100); + const delay = (i * 0.08).toFixed(2); + return `
+
+ + + + + ${Math.round(pct)} +
+ ${s.name} +
`; + }).join('')} +
+ `; +} + function populateArtistDetailPage(data) { const artist = data.artist; const discography = data.discography; diff --git a/webui/static/style.css b/webui/static/style.css index cb624d75..5a85e304 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -19729,6 +19729,109 @@ body { margin-top: 14px; } +/* Per-artist enrichment coverage */ +.artist-enrichment-coverage { + margin-top: 16px; +} + +.artist-enrichment-coverage .artist-enrich-title { + color: rgba(255, 255, 255, 0.35); + font-size: 0.65em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 10px; +} + +.artist-enrich-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px 10px; +} + +.artist-enrich-circle { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + cursor: default; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.artist-enrich-circle:hover { + transform: scale(1.12); +} + +.artist-enrich-circle:hover .ring-pct { + color: rgba(255, 255, 255, 0.9); +} + +.artist-enrich-circle:hover .artist-enrich-label { + color: rgba(255, 255, 255, 0.7); +} + +.artist-enrich-ring { + position: relative; + width: 48px; + height: 48px; + filter: drop-shadow(0 0 0px transparent); + transition: filter 0.3s ease; +} + +.artist-enrich-circle:hover .artist-enrich-ring { + filter: drop-shadow(0 0 6px var(--ring-color, rgba(255, 255, 255, 0.2))); +} + +.artist-enrich-ring svg { + transform: rotate(-90deg); + width: 48px; + height: 48px; +} + +.artist-enrich-ring .ring-bg { + fill: none; + stroke: rgba(255, 255, 255, 0.06); + stroke-width: 3; +} + +.artist-enrich-ring .ring-fill { + fill: none; + stroke-width: 3; + stroke-linecap: round; +} + +.artist-enrich-ring .ring-pct { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: rgba(255, 255, 255, 0.5); + transition: color 0.25s ease; +} + +.artist-enrich-label { + font-size: 0.7em; + color: rgba(255, 255, 255, 0.4); + font-weight: 500; + text-align: center; + line-height: 1.1; + transition: color 0.25s ease; +} + +@keyframes ringFillIn { + from { stroke-dashoffset: var(--ring-circ); } + to { stroke-dashoffset: var(--ring-offset); } +} + +@keyframes ringPctFade { + 0% { opacity: 0; transform: scale(0.7); } + 60% { opacity: 0; transform: scale(0.7); } + 100% { opacity: 1; transform: scale(1); } +} + .collection-category { flex: 1; display: flex;