Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
7.4 KiB
Go
217 lines
7.4 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"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=<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 := 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)
|
|
// Condition on the current device: the latest play's device is a
|
|
// cheap, request-free proxy for what the user is on right now.
|
|
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
|
|
|
|
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
|
// Size the pool to the library: a fixed ~170 candidates samples a shrinking
|
|
// fraction of a growing collection. Degrades to the base limits if the
|
|
// count is unavailable — never fails the request over a sizing hint.
|
|
librarySize := h.librarySize.Get(r.Context(), func(ctx context.Context) (int64, error) {
|
|
return recommendation.CountLibraryTracks(ctx, q)
|
|
})
|
|
limits := recommendation.ScaleForLibrary(
|
|
recommendation.DefaultCandidateSourceLimits(), librarySize,
|
|
)
|
|
candidates, err := recommendation.LoadCandidatesFromSimilarity(
|
|
r.Context(), q, user.ID, seedID,
|
|
h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits,
|
|
// A fresh seed per request: radio is a new session each time
|
|
// and SHOULD draw differently. The system mixes are the surfaces that
|
|
// promise repeatability; this is not one of them.
|
|
strconv.FormatInt(time.Now().UnixNano(), 36),
|
|
)
|
|
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 —
|
|
// read per request so an admin change takes effect live.
|
|
weights := h.recSettings.Weights(recsettings.ScopeRadio)
|
|
// Diversity caps, so a whole session cannot come back from one artist.
|
|
// Scaled to
|
|
// the requested length so a 20-track radio and a 200-track one are capped
|
|
// alike; Shuffle relaxes them rather than returning a short radio.
|
|
//
|
|
// limit-1 because the seed track occupies the first slot and is prepended
|
|
// below — the caps govern the tracks that FOLLOW it.
|
|
caps := recommendation.RadioDiversityCaps(limit - 1)
|
|
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1, caps)
|
|
|
|
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
|
|
}
|
|
|
|
// latestDeviceClass returns the device_class of the user's most recent play as
|
|
// the "current device" for context conditioning, or "" when unknown
|
|
// (no plays yet, or the latest play predates device capture). Best-effort: a
|
|
// lookup failure yields a device-agnostic ("") affinity cell.
|
|
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
|
|
dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
logger.Warn("api: radio: latest device class", "err", err)
|
|
}
|
|
return ""
|
|
}
|
|
if dc == nil {
|
|
return ""
|
|
}
|
|
return *dc
|
|
}
|
|
|
|
// 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
|
|
}
|