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)}