package api import ( "encoding/json" "errors" "log/slog" "net/http" "strconv" "strings" "time" "github.com/jackc/pgx/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/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" ) // RadioResponse is the body of GET /api/radio. type RadioResponse struct { Tracks []TrackRef `json:"tracks"` } // handleRadio implements GET /api/radio?seed_track=&limit=. // // Returns the seed at index 0, followed by up to limit-1 weighted-shuffle // picks from the user's library, scored by recommendation.Score. The // scoring formula folds in contextual_match_score using the user's current // session vector (read from the most recent open play_event). func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) if raw == "" { writeErr(w, apierror.BadRequest("bad_request", "seed_track is required")) return } seedID, ok := parseUUID(raw) if !ok { writeErr(w, apierror.BadRequest("bad_request", "invalid seed_track id")) return } limit := h.recCfg.RadioSize 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 } if limit > h.recCfg.RadioSizeMax { limit = h.recCfg.RadioSizeMax } q := dbq.New(h.pool) track, err := q.GetTrackByID(r.Context(), seedID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeErr(w, apierror.NotFound("seed_track")) return } h.logger.Error("api: get radio seed track failed", "err", err) writeErr(w, apierror.InternalMsg("lookup failed", err)) return } album, err := q.GetAlbumByID(r.Context(), track.AlbumID) if err != nil { h.logger.Error("api: get radio seed album failed", "err", err) writeErr(w, apierror.InternalMsg("lookup failed", err)) return } artist, err := q.GetArtistByID(r.Context(), track.ArtistID) if err != nil { h.logger.Error("api: get radio seed artist failed", "err", err) writeErr(w, apierror.InternalMsg("lookup failed", err)) return } currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) exclude := parseExcludeParam(r.URL.Query().Get("exclude")) limits := recommendation.DefaultCandidateSourceLimits() candidates, err := recommendation.LoadCandidatesFromSimilarity( r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits, ) if err != nil { h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err) candidates, err = recommendation.LoadCandidates( r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec, ) if err != nil { h.logger.Error("api: radio: load candidates fallback failed", "err", err) writeErr(w, apierror.InternalMsg("candidate load failed", err)) return } } // Scoring weights come from the DB-backed tuning lab (#1250) — // read per request so an admin change takes effect live. weights := h.recSettings.Weights(recsettings.ScopeRadio) picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) out := make([]TrackRef, 0, len(picks)+1) out = append(out, trackRefFrom(track, album.Title, artist.Name)) for _, p := range picks { al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID) if err != nil { h.logger.Error("api: radio: resolve album", "err", err) writeErr(w, apierror.InternalMsg("resolve failed", err)) return } ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID) if err != nil { h.logger.Error("api: radio: resolve artist", "err", err) writeErr(w, apierror.InternalMsg("resolve failed", err)) return } out = append(out, trackRefFrom(p.Track, al.Title, ar.Name)) } writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) } // loadCurrentSessionVector returns the user's most recent active session // vector, or a Seed=true sentinel if none exists / the column is NULL / // the JSON fails to unmarshal. Sentinel short-circuits ContextualMatchScore // to 0 so the contextual term contributes nothing in cold-start cases. func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) recommendation.SessionVector { raw, err := q.GetCurrentSessionVectorForUser(r.Context(), userID) if err != nil { // pgx.ErrNoRows is the common path: no active session yet. if !errors.Is(err, pgx.ErrNoRows) { logger.Warn("api: radio: load current session vector", "err", err) } return recommendation.SessionVector{Seed: true} } if len(raw) == 0 { return recommendation.SessionVector{Seed: true} } var v recommendation.SessionVector if jerr := json.Unmarshal(raw, &v); jerr != nil { logger.Warn("api: radio: bad session_vector_at_play json", "err", jerr) return recommendation.SessionVector{Seed: true} } return v } // parseExcludeParam parses a comma-separated list of UUIDs from the // `exclude` query string, silently dropping malformed entries. Returns // nil for empty or all-malformed input. func parseExcludeParam(raw string) []pgtype.UUID { if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]pgtype.UUID, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p == "" { continue } id, ok := parseUUID(p) if !ok { continue } out = append(out, id) } return out }