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