284271c190
Wire contextual scoring end-to-end: add ContextWeight to RecommendationConfig (struct + Default()), fetch the user's current session vector in handleRadio via loadCurrentSessionVector (cold-start returns Seed sentinel), and pass it as the 6th arg to LoadCandidates so ContextualMatchScore flows into Shuffle.
144 lines
4.9 KiB
Go
144 lines
4.9 KiB
Go
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/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
|
)
|
|
|
|
// RadioResponse is the body of GET /api/radio.
|
|
type RadioResponse struct {
|
|
Tracks []TrackRef `json:"tracks"`
|
|
}
|
|
|
|
// handleRadio implements GET /api/radio?seed_track=<uuid>&limit=<int>.
|
|
//
|
|
// 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 := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
|
return
|
|
}
|
|
raw := strings.TrimSpace(r.URL.Query().Get("seed_track"))
|
|
if raw == "" {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required")
|
|
return
|
|
}
|
|
seedID, ok := parseUUID(raw)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "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, http.StatusBadRequest, "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, http.StatusNotFound, "not_found", "seed_track not found")
|
|
return
|
|
}
|
|
h.logger.Error("api: get radio seed track failed", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
|
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, http.StatusInternalServerError, "server_error", "lookup failed")
|
|
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, http.StatusInternalServerError, "server_error", "lookup failed")
|
|
return
|
|
}
|
|
|
|
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
|
|
|
candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec)
|
|
if err != nil {
|
|
h.logger.Error("api: radio: load candidates", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed")
|
|
return
|
|
}
|
|
weights := recommendation.ScoringWeights{
|
|
BaseWeight: h.recCfg.BaseWeight,
|
|
LikeBoost: h.recCfg.LikeBoost,
|
|
RecencyWeight: h.recCfg.RecencyWeight,
|
|
SkipPenalty: h.recCfg.SkipPenalty,
|
|
JitterMagnitude: h.recCfg.JitterMagnitude,
|
|
ContextWeight: h.recCfg.ContextWeight,
|
|
}
|
|
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, http.StatusInternalServerError, "server_error", "resolve failed")
|
|
return
|
|
}
|
|
ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID)
|
|
if err != nil {
|
|
h.logger.Error("api: radio: resolve artist", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed")
|
|
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
|
|
}
|