feat(discover): on-demand Lidarr artist art for suggestions

Out-of-library suggestion artists (artist_similarity_unmatched rows)
have no local art row, so the Discover card always showed a
placeholder. Resolve art on-demand from Lidarr's artist lookup,
matched by MBID (foreignArtistId == candidate_mbid), and pass the
remote image URL straight through — no caching (a suggestion may
never be viewed; the browser fetches the URL directly).

- suggestionView gains image_url (omitempty); handler resolves it via
  bounded-concurrency Lidarr LookupArtist, best-effort, never fails
  the request.
- Lidarr is the sole source: disabled / unreachable / no-match →
  empty → existing placeholder. (No inline TheAudioDB fallback — it's
  externally rate-limited and there's no cache to amortize it.)
- web: ArtistSuggestion.image_url?; SuggestionFeed passes it to
  DiscoverResultCard (already supports imageUrl).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 20:16:56 -04:00
parent 9ffe33a6f2
commit a7bea43a13
3 changed files with 57 additions and 0 deletions
+55
View File
@@ -1,12 +1,15 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"strconv" "strconv"
"sync"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
) )
@@ -16,6 +19,11 @@ type suggestionView struct {
Name string `json:"name"` Name string `json:"name"`
Score float64 `json:"score"` Score float64 `json:"score"`
Attribution []seedContributionView `json:"attribution"` Attribution []seedContributionView `json:"attribution"`
// ImageURL is resolved on-demand from Lidarr (out-of-library
// artists have no local art row). Omitted when Lidarr is disabled
// or has no match — the client falls back to a placeholder. Not
// cached: a remote URL Lidarr surfaced, fetched by the browser.
ImageURL string `json:"image_url,omitempty"`
} }
type seedContributionView struct { type seedContributionView struct {
@@ -80,5 +88,52 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr, MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
}) })
} }
h.resolveSuggestionArt(r.Context(), out)
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
} }
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
// Lidarr is the only source — when it's disabled, unreachable, or has
// no match for a candidate, that entry keeps an empty ImageURL and the
// client renders its placeholder. Lookups run with bounded concurrency
// so a full Discover page doesn't serialize ~12 round-trips. Never
// fails the request; the suggestions list is the contract, art is a
// nicety.
func (h *handlers) resolveSuggestionArt(ctx context.Context, views []suggestionView) {
if len(views) == 0 {
return
}
cfg, err := h.lidarrCfg.Get(ctx)
if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
return // Lidarr off / unconfigured → placeholders only
}
client := lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
const maxConcurrent = 6
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for i := range views {
if views[i].MBID == "" || views[i].Name == "" {
continue
}
wg.Add(1)
sem <- struct{}{}
go func(idx int) {
defer wg.Done()
defer func() { <-sem }()
results, lerr := client.LookupArtist(ctx, views[idx].Name)
if lerr != nil {
return // best-effort: no art on lookup failure
}
for _, res := range results {
if res.MBID == views[idx].MBID && res.ImageURL != "" {
// Distinct slice index per goroutine → race-free.
views[idx].ImageURL = res.ImageURL
return
}
}
}(i)
}
wg.Wait()
}
+1
View File
@@ -298,6 +298,7 @@ export type ArtistSuggestion = {
name: string; name: string;
score: number; score: number;
attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC
image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder
}; };
// Mirrors internal/api/types.go HomePayload. All slices are non-null // Mirrors internal/api/types.go HomePayload. All slices are non-null
@@ -65,6 +65,7 @@
<DiscoverResultCard <DiscoverResultCard
kind="artist" kind="artist"
title={s.name} title={s.name}
imageUrl={s.image_url}
state="requestable" state="requestable"
attribution={attributionText(s.attribution)} attribution={attributionText(s.attribution)}
onRequest={() => onRequest(s)} onRequest={() => onRequest(s)}