From f936b8cb12ae8009c2c73f75bc53bf234e303ec3 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:14:39 -0700 Subject: [PATCH] Enrich source-only artist-detail response and skip discography dedup for source artists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/metadata_service.py | 11 +++- web_server.py | 119 ++++++++++++++++++++++++++++++++++++--- webui/static/library.js | 14 ++++- 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/core/metadata_service.py b/core/metadata_service.py index 04013a49..15387201 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -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) diff --git a/web_server.py b/web_server.py index 58914c45..32aeb9e1 100644 --- a/web_server.py +++ b/web_server.py @@ -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/` - 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//image + resolved_name = (artist_name or artist_id or '').strip() + + # 1. Image URL via the same helper /api/artist//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({ diff --git a/webui/static/library.js b/webui/static/library.js index 74b3e2a5..7952fcfc 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -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