Files
minstrel/internal/api/radio.go
T
bvandeusen 5749f48b4a
test-go / test (push) Successful in 45s
test-web / test (push) Successful in 52s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 5m5s
feat(taste): device-class context conditioning — #1551
Milestone #160 Opt 3b. Adds device class as a third context axis on top
of the #1531 time-of-day/weekday affinity: on the radio path, a candidate
is boosted when its artist concentrates in the current (daypart × weekday
× device) cell. Client-sent (client_id is opaque; no UA stored), so it's
captured going forward and applies to radio only (daily mixes are
cron-built with no device → stay device-agnostic).

Server:
- Migration 0048: play_events.device_class text NULL (no CHECK; normalized
  in Go — one whitelist entry per new client class, not a migration).
- events.go: eventRequest.device_class + normalizeDeviceClass (whitelist →
  mobile/web/…, else "other", empty → NULL); threaded through both
  RecordPlayStartedWithSource and RecordOfflinePlay into InsertPlayEvent.
- ListArtistContextPlayCountsForUser gains a current-device param; the cell
  FILTER adds AND ($2='' OR device_class=$2) — '' reproduces the #1531
  time-only behaviour exactly (used by mixes). SessionVector.DeviceClass
  carries it; the radio handler derives the current device from the user's
  latest play (GetLatestPlayDeviceClassForUser) — request-free proxy.
- No new tuning knob: device narrows the existing ContextAffinityScore
  (reuses context_time_weight).

Clients:
- web: play_started sends device_class 'web'.
- android: play_started + offline replay send 'mobile' (EventsWire +
  PlayOfflinePayload + MutationReplayer + PlayEventsReporter).

Test: LoadContextAffinity device-narrowing integration test (mobile vs web
artist separation; device-agnostic parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:47:59 -04:00

197 lines
6.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 (#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"))
limits := recommendation.DefaultCandidateSourceLimits()
candidates, err := recommendation.LoadCandidatesFromSimilarity(
r.Context(), q, user.ID, seedID,
h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits,
)
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)
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, 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
}