package api import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "sync" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "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) } // Snooze duration bounds. 90 days is long enough that a parked suggestion // stops feeling like it's nagging, short enough that a taste shift brings it // back on its own — the whole point of a snooze over a dismissal (#2374). const ( defaultSnoozeDays = 90.0 maxSnoozeDays = 365.0 ) // snoozeRequest is the POST body. Both fields are optional in the JSON sense // (an absent body snoozes for the default), but Name is required in practice: // candidates are out-of-library, so the server has no artists row to resolve a // display name from and the un-snooze list would have nothing to show. The // client always has it — it just rendered the card. type snoozeRequest struct { Name string `json:"name"` Days float64 `json:"days"` } // snoozeView is one row of GET /api/discover/snoozes. type snoozeView struct { MBID string `json:"mbid"` Name string `json:"name"` SnoozedUntil pgtype.Timestamptz `json:"snoozed_until"` CreatedAt pgtype.Timestamptz `json:"created_at"` } // handleSnoozeSuggestion implements // POST /api/discover/suggestions/{mbid}/snooze. // // Parks a candidate for `days` (default 90, capped at 365). Idempotent: // snoozing an already-snoozed candidate extends it rather than conflicting. // // This is NOT negative feedback. It records no verdict on the artist and is // never read by internal/taste — see 0049_suggestion_snoozes.up.sql for the // rule #101 reasoning. Returns 204. func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } mbid := strings.TrimSpace(chi.URLParam(r, "mbid")) if mbid == "" { writeErr(w, apierror.BadRequest("invalid_id", "missing mbid")) return } // An empty body is a valid "snooze this for the default period", so EOF // is not an error here — decodeBody would reject it as a malformed body. var body snoozeRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) { writeErr(w, apierror.BadRequest("invalid_body", "")) return } name := strings.TrimSpace(body.Name) if name == "" { writeErr(w, apierror.BadRequest("invalid_body", "name is required")) return } days := body.Days if days <= 0 { days = defaultSnoozeDays } if days > maxSnoozeDays { // Clamp rather than reject: a client asking for longer than we allow // still means "park this", and failing the write would leave the card // sitting there as if the tap did nothing. days = maxSnoozeDays } q := dbq.New(h.pool) if err := q.SnoozeSuggestion(r.Context(), dbq.SnoozeSuggestionParams{ UserID: user.ID, CandidateMbid: mbid, CandidateName: name, Column4: days, }); err != nil { h.logger.Error("api: snooze suggestion", "err", err) writeErr(w, apierror.InternalMsg("failed to snooze suggestion", err)) return } w.WriteHeader(http.StatusNoContent) } // handleUnsnoozeSuggestion implements // DELETE /api/discover/suggestions/{mbid}/snooze. // // Brings a parked candidate back immediately. 404s an MBID this user never // snoozed, so the client can tell "undone" from "there was nothing there". func (h *handlers) handleUnsnoozeSuggestion(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } mbid := strings.TrimSpace(chi.URLParam(r, "mbid")) if mbid == "" { writeErr(w, apierror.BadRequest("invalid_id", "missing mbid")) return } q := dbq.New(h.pool) rows, err := q.UnsnoozeSuggestion(r.Context(), dbq.UnsnoozeSuggestionParams{ UserID: user.ID, CandidateMbid: mbid, }) if err != nil { h.logger.Error("api: unsnooze suggestion", "err", err) writeErr(w, apierror.InternalMsg("failed to unsnooze suggestion", err)) return } if rows == 0 { writeErr(w, apierror.NotFound("snooze")) return } w.WriteHeader(http.StatusNoContent) } // handleListSuggestionSnoozes implements GET /api/discover/snoozes. // // The un-snooze surface needs this: a parked candidate is by definition // absent from the suggestion deck, so without a list there is no way to // reach the DELETE above. Scoped to the caller (rule #47). Expired rows are // already filtered by the query — the hourly gc sweep only reclaims space. func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } rows, err := dbq.New(h.pool).ListActiveSuggestionSnoozes(r.Context(), user.ID) if err != nil { h.logger.Error("api: list suggestion snoozes", "err", err) writeErr(w, apierror.InternalMsg("failed to load snoozes", err)) return } out := make([]snoozeView, 0, len(rows)) for _, row := range rows { out = append(out, snoozeView{ MBID: row.CandidateMbid, Name: row.CandidateName, SnoozedUntil: row.SnoozedUntil, CreatedAt: row.CreatedAt, }) } 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() }