feat(api): rewrite /api/radio with weighted shuffle v1
Validates seed_track + optional limit (default cfg.Recommendation.RadioSize, clamped to RadioSizeMax). Calls recommendation.LoadCandidates + recommendation.Shuffle. Prepends seed to result. Server seeds a math/rand source at startup; handlers package threads that as a func() float64 so tests inject deterministic RNGs. Mount + server.New gain a RecommendationConfig parameter.
This commit is contained in:
+59
-9
@@ -3,11 +3,15 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"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.
|
||||
@@ -15,24 +19,40 @@ type RadioResponse struct {
|
||||
Tracks []TrackRef `json:"tracks"`
|
||||
}
|
||||
|
||||
// handleRadio implements GET /api/radio?seed_track=<uuid>.
|
||||
// handleRadio implements GET /api/radio?seed_track=<uuid>&limit=<int>.
|
||||
//
|
||||
// M6 stub: returns a queue containing only the seed track. The full M4
|
||||
// implementation (similarity-based candidate pool + scoring) replaces the
|
||||
// body without changing the request/response shape.
|
||||
// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle
|
||||
// picks from the user's library, scored by recommendation.Score.
|
||||
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
|
||||
}
|
||||
id, ok := parseUUID(raw)
|
||||
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(), id)
|
||||
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")
|
||||
@@ -54,7 +74,37 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, RadioResponse{
|
||||
Tracks: []TrackRef{trackRefFrom(track, album.Title, artist.Name)},
|
||||
})
|
||||
candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours)
|
||||
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,
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user