Source-aware /api/artist-detail: fall back to metadata source when not in library

Part A of the deferred unification cleanup. The standalone artist-
detail endpoint used to 404 whenever `artist_id` wasn't a local library
primary key, which is exactly what source artists (Deezer/Spotify/
iTunes/etc.) have. That forced the Phase 4a revert: source artists had
to use the inline Artists page because this endpoint couldn't handle
them.

New behaviour:
  - Library PK path — unchanged. Existing callers see the same response.
  - `/api/artist-detail/<id>?source=<src>&name=<name>` with source in
    (spotify, itunes, deezer, discogs, hydrabase, musicbrainz) — when
    the library DB lookup misses, synthesize a response by:
      • fetching artist image via metadata_service.get_artist_image_url
        with source_override (the helper already backing /api/artist/
        <id>/image)
      • fetching discography via metadata_service.get_artist_detail_
        discography with MetadataLookupOptions(source_override=source,
        artist_source_ids={source: artist_id})
      • returning { success, artist: {id, name, image_url, server_source:
        null, genres: []}, discography, enrichment_coverage: {} }
  - Library PK missing AND no source — preserves the 404 (caller didn't
    give enough info to fall back).

Frontend plumbing: library.js loadArtistDetailData now appends
?source=<src>&name=<name> to the fetch URL when
artistDetailPageState.currentArtistSource is set. The field is already
seeded by navigateToArtistDetail's third arg (added during the earlier
unification work), so no new state plumbing is needed.

populateArtistDetailPage gracefully handles the missing-library-data
case per earlier exploration — owned_releases empty is fine,
enrichment_coverage optional, spotify_artist_id optional.

Part B will re-route the source-artist callsites (Search / Discover /
Watchlist / etc.) back through navigateToArtistDetail so they actually
exercise this new fallback path.
This commit is contained in:
Broque Thomas 2026-04-22 15:28:28 -07:00
parent a5d97261e4
commit 89754480be
2 changed files with 106 additions and 4 deletions

View file

@ -11316,11 +11316,93 @@ def test_artist_endpoint(artist_id):
"message": f"Test endpoint working for artist ID: {artist_id}"
})
_SOURCE_ONLY_ARTIST_SOURCES = frozenset({
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
})
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."""
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
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.
discography_result = _get_artist_detail_discography(
artist_id,
artist_name=artist_name or artist_id,
options=MetadataLookupOptions(
source_override=source,
allow_fallback=True,
skip_cache=False,
max_pages=0,
limit=50,
artist_source_ids={source: artist_id},
),
)
if not discography_result.get('success'):
return jsonify({
"success": False,
"error": discography_result.get('error', 'Could not load discography'),
"source": source,
}), 404
artist_info = {
"id": artist_id,
"name": artist_name or artist_id,
"image_url": image_url,
"server_source": None, # not in library
"genres": [],
}
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', []))}"
)
return jsonify({
"success": True,
"artist": artist_info,
"discography": discography_result,
"enrichment_coverage": {},
})
@app.route('/api/artist-detail/<artist_id>')
def get_artist_detail(artist_id):
"""Get artist detail data"""
"""Get artist detail data.
For library artists, `artist_id` is the local DB primary key and the full
library-aware path runs (owned releases + merged source discography + per-
service enrichment coverage).
For source artists (Spotify/Deezer/iTunes/etc. that aren't in the library
yet), pass `?source=<source>&name=<artist_name>` and the endpoint synthesizes
a response directly from the metadata source no owned releases, just name +
image + discography so the artist-detail page can still render.
"""
try:
logger.info(f"Getting artist detail for ID: {artist_id}")
source_param = (request.args.get('source', '') or '').strip().lower()
artist_name_arg = (request.args.get('name', '') or '').strip()
logger.info(
f"Getting artist detail for ID: {artist_id} "
f"(source={source_param or 'library'})"
)
# Get database instance
database = get_database()
@ -11329,6 +11411,13 @@ def get_artist_detail(artist_id):
db_result = database.get_artist_discography(artist_id)
if not db_result.get('success'):
# Library lookup failed. If a metadata source was specified, fall back
# to a source-only response so the page can render a non-library artist.
if source_param in _SOURCE_ONLY_ARTIST_SOURCES:
return _build_source_only_artist_detail(
artist_id, artist_name_arg, source_param
)
logger.error(f"Database returned error: {db_result}")
return jsonify({
"success": False,

View file

@ -764,8 +764,21 @@ async function loadArtistDetailData(artistId, artistName) {
// Don't update header until data loads to avoid showing stale data
try {
// Call API to get artist discography data
const response = await fetch(`/api/artist-detail/${artistId}`);
// Call API to get artist discography data. If this artist came from a
// metadata source (not the library), pass source + name so the backend
// can synthesize a response from that source instead of 404ing on the
// local DB lookup.
const params = new URLSearchParams();
if (artistDetailPageState.currentArtistSource) {
params.set('source', artistDetailPageState.currentArtistSource);
}
if (artistName) {
params.set('name', artistName);
}
const qs = params.toString();
const response = await fetch(
`/api/artist-detail/${encodeURIComponent(artistId)}${qs ? '?' + qs : ''}`
);
if (!response.ok) {
throw new Error(`Failed to load artist data: ${response.statusText}`);