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