5749f48b4a
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>
197 lines
6.4 KiB
Go
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
|
|
}
|