test-go / test (push) Successful in 1m18s
test-go / integration (push) Successful in 4m52s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Build + push container image (push) Successful in 2m5s
release / Verify release artifacts (tag releases only) (push) Skipped
Four arms of the candidate query ended in a bare `ORDER BY random()` with no seed: similar_artists, likes_overlap, coplay_artists and random_fill. Such an arm returns a STABLE set only while its LIMIT exceeds the rows eligible for it — at that point it returns all of them and the order stops mattering, because scoreAndSortCandidates sorts by track id before drawing jitter. Below that threshold it returns a random SUBSET, and two builds on the same day draw different ones. So daily determinism held BY ACCIDENT, and only for libraries smaller than the limits. Any real library is larger, which means same-day rebuilds have been producing different mixes since those arms were written — invisible, because a mix that changes after a refresh looks like a feature rather than a broken promise. Found by breaking it: cutting RandomFill to 10 while tuning Songs-like turned TestBuildSystemPlaylists_DailyNonceDeterminism red. That test seeds ~20 tracks against a default RandomFill of 30, so its determinism came from the limit exceeding the library, not from the code being right. It is now a real guard. The arms order by md5(id || $12) instead. The CALLER decides what that means, which is the point: system mixes pass a per-(user, day) seed and get the determinism they promise, radio passes a fresh value per request and keeps varying, which is what a radio should do. Same shape the browse queries in this file already use (`md5(id::text || current_date::text)`) — existing idiom, not a new one. This also unblocks the trim that #3881 wanted and could not have. Shrinking a randomly-ordered arm was what broke membership; a seeded one takes a smaller but REPRODUCIBLE slice. Songs-like's seed-independent share drops from 29% to 12%, which was the original intent before determinism forced it back to 20%. TestSongsLikeLimits_DoNotShrinkTheUnseededRandomArms is DELETED rather than kept passing. It existed to stop anyone trimming those arms while the ordering was broken; the ordering is fixed, so the constraint is gone and a guard enforcing it would now forbid correct code. Was filed as blocked on tooling. It was not: `make generate-go` runs sqlc as a pinned Go tool and is the same path CI takes. One thing worth knowing for next time: three files in internal/db/dbq are owned by root, left by `make generate` running sqlc in Docker. sqlc errored on the first it could not write. They are untouched by this change and the regeneration of recommendation.sql.go completed, but `make generate` will keep failing until they are chowned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
219 lines
7.6 KiB
Go
219 lines
7.6 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 (#1551): 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 (#3880). A fixed ~170 candidates samples a
|
|
// shrinking fraction of a growing collection, which is what made the
|
|
// recommendations feel less relevant as the library grew. 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 (#3889): 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 lab (#1250) —
|
|
// read per request so an admin change takes effect live.
|
|
weights := h.recSettings.Weights(recsettings.ScopeRadio)
|
|
// Diversity caps (#3882). Radio had none while every sibling surface did,
|
|
// which is how a whole session could 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 (#1551), 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
|
|
}
|