Enrich source-only artist-detail response and skip discography dedup for source artists

Source artists landing on /artist-detail were rendering an almost-blank
hero — image + name + a tiny Download button — because the backend
response only had {id, name, image_url, server_source: null, genres: []}.
The library.js renderers do their best with what they have, and that
wasn't much.

Backend changes (_build_source_only_artist_detail):
  - Set the source-specific ID field (deezer_id / spotify_artist_id /
    itunes_artist_id / discogs_id / soul_id / musicbrainz_id) on
    artist_info so the corresponding service badge renders on the hero.
  - Try the source's own get_artist_info / get_artist for genres +
    followers (Spotify always; Deezer/iTunes/Discogs when available).
    Spotify also fills image_url if metadata_service.get_artist_image_url
    came up empty.
  - Last.fm enrichment by artist name — bio + listeners + playcount +
    lastfm_url. Mirrors what library artists get from the cached
    enrichment workers but on demand for source artists.
  - All enrichment lookups are wrapped in try/except so a 500 from any
    one source doesn't break the whole response.

Frontend (library.js populateArtistDetailPage):
  - Watchlist button now initialises for source artists too. Falls back
    to artist.id + artist.name when there's no canonical Spotify
    identity (which is the common case for non-library artists).

Discography dedup opt-out:
  - Added dedup_variants flag to MetadataLookupOptions (default True so
    library artists are unchanged). Source-only path now passes
    dedup_variants=False so every "Deluxe Edition" / "Remastered" /
    "Anniversary" variant the source returns is shown — matches the
    inline /artists page behaviour the user was comparing against.

Result: source artists' hero now shows badges + bio + listeners +
playcount + watchlist button + genres in addition to image and name.
Discography lists every release the source returns, not the deduped
canonical view.
pull/361/head
Broque Thomas 1 month ago
parent 93f1941829
commit f936b8cb12

@ -36,6 +36,10 @@ class MetadataLookupOptions:
max_pages: int = 0
limit: int = 50
artist_source_ids: Optional[Dict[str, str]] = None
dedup_variants: bool = True # Collapse "Deluxe Edition" / "Remastered" etc.
# into a single canonical release card. Off
# gives the inline-Artists-page behaviour of
# showing every variant the source returns.
# =============================================================================
@ -633,9 +637,10 @@ def get_artist_detail_discography(
else:
albums.append(card)
albums = _dedup_variant_releases(albums)
eps = _dedup_variant_releases(eps)
singles = _dedup_variant_releases(singles)
if options is None or options.dedup_variants:
albums = _dedup_variant_releases(albums)
eps = _dedup_variant_releases(eps)
singles = _dedup_variant_releases(singles)
albums = _sort_discography_releases(albums)
eps = _sort_discography_releases(eps)

@ -11321,28 +11321,112 @@ _SOURCE_ONLY_ARTIST_SOURCES = frozenset({
})
_SOURCE_ID_FIELD = {
'spotify': 'spotify_artist_id',
'itunes': 'itunes_artist_id',
'deezer': 'deezer_id',
'discogs': 'discogs_id',
'hydrabase': 'soul_id',
'musicbrainz': 'musicbrainz_id',
}
def _build_source_only_artist_detail(artist_id, artist_name, source):
"""Synthesize an artist-detail response from a single metadata source for an
artist that isn't in the local library. Used when `/api/artist-detail/<id>`
is called with a `source` param and the library DB lookup misses."""
is called with a `source` param and the library DB lookup misses.
Enriches the response with whatever metadata we can pull on demand:
- Image URL via metadata_service helper
- Source-specific ID field (so the source's own service badge renders)
- Genres from the source's get_artist_info if available
- Last.fm bio + listeners + playcount + URL by artist name
"""
from core.metadata_service import (
MetadataLookupOptions,
get_artist_detail_discography as _get_artist_detail_discography,
get_artist_image_url as _get_artist_image_url,
)
# Resolve artist image via the same helper that powers /api/artist/<id>/image
resolved_name = (artist_name or artist_id or '').strip()
# 1. Image URL via the same helper /api/artist/<id>/image uses.
image_url = None
try:
image_url = _get_artist_image_url(artist_id, source_override=source)
except Exception as e:
logger.debug(f"Artist image lookup failed for {source}:{artist_id}: {e}")
# Fetch discography from the specified source, with source_override pinned so
# the fallback chain starts with the caller-requested provider.
# 2. Source-side artist info (image, genres, followers depending on source).
source_genres = []
source_followers = None
try:
if source == 'spotify' and spotify_client and spotify_client.is_spotify_authenticated():
sp_artist = spotify_client.get_artist(artist_id, allow_fallback=False)
if sp_artist:
source_genres = sp_artist.get('genres') or []
source_followers = (sp_artist.get('followers') or {}).get('total')
if not image_url and sp_artist.get('images'):
image_url = sp_artist['images'][0].get('url')
elif source == 'deezer':
dz = _get_deezer_client()
if dz:
dz_artist = dz.get_artist_info(artist_id)
if dz_artist:
source_genres = dz_artist.get('genres') or []
source_followers = (dz_artist.get('followers') or {}).get('total')
elif source == 'itunes':
it = _get_itunes_client()
if it:
it_artist = it.get_artist(artist_id)
if it_artist:
source_genres = it_artist.get('genres') or []
elif source == 'discogs':
token = config_manager.get('discogs.token', '')
if token:
dc = _get_discogs_client(token)
if dc:
dc_artist = dc.get_artist(artist_id)
if dc_artist:
source_genres = dc_artist.get('genres') or []
except Exception as e:
logger.debug(f"Source-side artist info lookup failed for {source}:{artist_id}: {e}")
# 3. Last.fm enrichment by artist name — bio + listeners + playcount + url.
lastfm_bio = None
lastfm_listeners = None
lastfm_playcount = None
lastfm_url = None
if resolved_name:
try:
from core.lastfm_client import LastFMClient
lastfm = LastFMClient()
if lastfm and getattr(lastfm, 'enabled', True):
lf_info = lastfm.get_artist_info(resolved_name)
if lf_info:
bio_obj = lf_info.get('bio') or {}
lastfm_bio = bio_obj.get('content') or bio_obj.get('summary')
stats_obj = lf_info.get('stats') or {}
if stats_obj.get('listeners'):
try:
lastfm_listeners = int(stats_obj['listeners'])
except (ValueError, TypeError):
pass
if stats_obj.get('playcount'):
try:
lastfm_playcount = int(stats_obj['playcount'])
except (ValueError, TypeError):
pass
lastfm_url = lf_info.get('url')
except Exception as e:
logger.debug(f"Last.fm enrichment failed for {resolved_name}: {e}")
# 4. Discography from the specified source. Skip the variant-dedup so the
# page shows every release the source returns — matches the inline
# Artists-page behaviour the user is comparing against.
discography_result = _get_artist_detail_discography(
artist_id,
artist_name=artist_name or artist_id,
artist_name=resolved_name or artist_id,
options=MetadataLookupOptions(
source_override=source,
allow_fallback=True,
@ -11350,6 +11434,7 @@ def _build_source_only_artist_detail(artist_id, artist_name, source):
max_pages=0,
limit=50,
artist_source_ids={source: artist_id},
dedup_variants=False,
),
)
@ -11362,17 +11447,35 @@ def _build_source_only_artist_detail(artist_id, artist_name, source):
artist_info = {
"id": artist_id,
"name": artist_name or artist_id,
"name": resolved_name or artist_id,
"image_url": image_url,
"server_source": None, # not in library
"genres": [],
"genres": source_genres,
}
# Set the source-specific ID so the corresponding service badge renders on
# the hero (e.g. source=deezer -> deezer_id; source=spotify -> spotify_artist_id).
source_id_field = _SOURCE_ID_FIELD.get(source)
if source_id_field:
artist_info[source_id_field] = artist_id
if source_followers is not None:
artist_info['followers'] = source_followers
if lastfm_bio:
artist_info['lastfm_bio'] = lastfm_bio
if lastfm_listeners is not None:
artist_info['lastfm_listeners'] = lastfm_listeners
if lastfm_playcount is not None:
artist_info['lastfm_playcount'] = lastfm_playcount
if lastfm_url:
artist_info['lastfm_url'] = lastfm_url
logger.info(
f"Source-only artist-detail: {artist_info['name']} from {source}"
f"albums={len(discography_result.get('albums', []))}, "
f"eps={len(discography_result.get('eps', []))}, "
f"singles={len(discography_result.get('singles', []))}"
f"singles={len(discography_result.get('singles', []))}, "
f"genres={len(source_genres)}, lastfm_bio={'yes' if lastfm_bio else 'no'}"
)
return jsonify({

@ -966,10 +966,18 @@ function populateArtistDetailPage(data) {
// Populate discography sections
populateDiscographySections(discography);
// Initialize library watchlist button if it exists (for library page)
// Initialize the watchlist button. Library artists that have been enriched
// get the canonical Spotify identity; source artists fall back to the id
// they came in with (Deezer/iTunes/Discogs/etc.).
const libraryWatchlistBtn = document.getElementById('library-artist-watchlist-btn');
if (libraryWatchlistBtn && data.spotify_artist && data.spotify_artist.spotify_artist_id) {
initializeLibraryWatchlistButton(data.spotify_artist.spotify_artist_id, data.spotify_artist.spotify_artist_name);
if (libraryWatchlistBtn) {
const watchlistId = (data.spotify_artist && data.spotify_artist.spotify_artist_id)
|| artist.id;
const watchlistName = (data.spotify_artist && data.spotify_artist.spotify_artist_name)
|| artist.name;
if (watchlistId && watchlistName) {
initializeLibraryWatchlistButton(watchlistId, watchlistName);
}
}
// Load Similar Artists section (works for both library + source artists via

Loading…
Cancel
Save