From a7bea43a1347d94e1ab75a59a1122d1b6969ad80 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 16 May 2026 20:16:56 -0400 Subject: [PATCH] feat(discover): on-demand Lidarr artist art for suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/api/suggestions.go | 55 ++++++++++++++++++++ web/src/lib/api/types.ts | 1 + web/src/lib/components/SuggestionFeed.svelte | 1 + 3 files changed, 57 insertions(+) diff --git a/internal/api/suggestions.go b/internal/api/suggestions.go index ba005a24..1ad5bf7f 100644 --- a/internal/api/suggestions.go +++ b/internal/api/suggestions.go @@ -1,12 +1,15 @@ package api import ( + "context" "net/http" "strconv" + "sync" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) @@ -16,6 +19,11 @@ type suggestionView struct { Name string `json:"name"` Score float64 `json:"score"` 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 { @@ -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, }) } + h.resolveSuggestionArt(r.Context(), 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() +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index d4235b42..667d4b49 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -298,6 +298,7 @@ export type ArtistSuggestion = { name: string; score: number; 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 diff --git a/web/src/lib/components/SuggestionFeed.svelte b/web/src/lib/components/SuggestionFeed.svelte index 6f16b641..fa37c26b 100644 --- a/web/src/lib/components/SuggestionFeed.svelte +++ b/web/src/lib/components/SuggestionFeed.svelte @@ -65,6 +65,7 @@ onRequest(s)}