Files
minstrel/internal/api/suggestions.go
T
bvandeusen a7bea43a13 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>
2026-05-16 20:16:56 -04:00

140 lines
4.3 KiB
Go

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"
)
// suggestionView is the wire shape returned by GET /api/discover/suggestions.
type suggestionView struct {
MBID string `json:"mbid"`
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 {
ArtistID pgtype.UUID `json:"artist_id"`
Name string `json:"name"`
Contribution float64 `json:"contribution"`
IsLiked bool `json:"is_liked"`
PlayCount int64 `json:"play_count"`
}
// handleListSuggestions implements GET /api/discover/suggestions.
//
// Query params:
// - limit (default 12, capped at 50)
// - half_life_days (default 30)
//
// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate.
func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
limit := 12
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_request", "invalid limit"))
return
}
limit = n
}
halfLife := 30.0
if v := r.URL.Query().Get("half_life_days"); v != "" {
f, err := strconv.ParseFloat(v, 64)
if err != nil || f <= 0 {
writeErr(w, apierror.BadRequest("bad_request", "invalid half_life_days"))
return
}
halfLife = f
}
suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit)
if err != nil {
h.logger.Error("api: list suggestions", "err", err)
writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
return
}
out := make([]suggestionView, 0, len(suggestions))
for _, s := range suggestions {
attr := make([]seedContributionView, 0, len(s.Attribution))
for _, a := range s.Attribution {
attr = append(attr, seedContributionView{
ArtistID: a.ArtistID,
Name: a.Name,
Contribution: a.Contribution,
IsLiked: a.IsLiked,
PlayCount: a.PlayCount,
})
}
out = append(out, suggestionView{
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()
}