release / web (push) Successful in 1m24s
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 25s
release / integration (push) Failing after 4m38s
release / android (push) Successful in 4m3s
release / Build signed APK (push) Successful in 4m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A new system playlist kind, genre_mix: up to 10 "{Genre} mix" playlists per
user, rebuilt daily with the other system mixes.
Which genres: library genres with at least 25 playable tracks across 3
artists, ranked in tiers — taste-profile genres first (matched
case-insensitively, so ID3 "Post-Punk" and folksonomy "post-punk" are one),
then genres the user has played or liked, then the rest of the library, which
only tops a short shelf up to 4. A genre whose artist set is a near
duplicate of one already picked (Jaccard >= 0.5) is skipped, so "Lo-Fi" and
"Lo-Fi Hip Hop" don't both appear but "Alternative Rock" survives "Rock". A
genre that can't fill 15 tracks gives way to the next.
Each mix is 50 tracks: 3 in 5 the user already knows (played through or
liked), the rest unheard in that genre, interleaved, with the usual
per-album/per-artist caps. Tracks skipped twice and more often than played
are left out. The order changes daily and holds within a day. Picks are
stamped taste / taste_unheard, and genre_mix plays count toward rotation and
the recommendation metrics.
Migration 0102 adds genre_mix to the playlists variant constraints.
Web and Android each get a "Your genres" row in name order, hidden until a
mix exists. Kinds with several playlists per user now play by playlist id
wherever the client used to test seed_artist_id (web card, detail page
self-heal), keyed on the server's refreshable flag; on Android genre_mix
joins songs_like_artist as not refreshable. The web detail page's
"mix was rebuilt" reload now finds a Songs-like or genre mix by name instead
of calling the by-kind shuffle, which those kinds don't have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
13 KiB
Go
370 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
const (
|
|
recMetricsDefaultDays = 30
|
|
recMetricsMaxDays = 365
|
|
|
|
// recMetricsLowVolume marks a family as low-confidence rather than
|
|
// hiding it: with fewer plays than this a skip rate is anecdote, not
|
|
// signal, but silently dropping the row would misread as "surface
|
|
// unused". The web renders low-confidence rows dimmed.
|
|
recMetricsLowVolume = 20
|
|
)
|
|
|
|
// Surface intents: each family is judged against its
|
|
// job, not one global bar — discovery mixes are EXPECTED to run higher
|
|
// skip rates than the go-to surfaces.
|
|
const (
|
|
intentGoTo = "go_to"
|
|
intentDiscovery = "discovery"
|
|
intentDirect = "direct"
|
|
)
|
|
|
|
// surfaceMetric is one bucketed surface family's outcomes.
|
|
type surfaceMetric struct {
|
|
Key string `json:"key"` // stable family key ("for_you", "radio", …)
|
|
Label string `json:"label"` // display label
|
|
Plays int64 `json:"plays"` // plays launched from this family
|
|
Skips int64 `json:"skips"` // of those, marked skipped
|
|
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
|
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
|
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
|
// SkipDelta / CompletionDelta are this row's difference from the manual
|
|
// baseline WITH its margin of error. nil on the baseline row
|
|
// itself, and whenever the samples are too thin for a margin to mean
|
|
// anything. Computed server-side so both clients read the same arithmetic
|
|
// instead of each re-deriving it. `low_confidence` is a display
|
|
// threshold, not a decision one.
|
|
SkipDelta *metricDelta `json:"skip_delta,omitempty"`
|
|
CompletionDelta *metricDelta `json:"completion_delta,omitempty"`
|
|
// Breakdown splits the family into the pick-kind populations its
|
|
// builder stamped: For You's taste/fresh,
|
|
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
|
// that predate attribution. Present only when the family has at
|
|
// least one attributed play; the parent row remains the sum of its
|
|
// breakdown.
|
|
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
|
|
}
|
|
|
|
// surfaceGroup is one intent band of surface families.
|
|
type surfaceGroup struct {
|
|
Intent string `json:"intent"` // go_to | discovery | direct
|
|
Label string `json:"label"`
|
|
Surfaces []surfaceMetric `json:"surfaces"`
|
|
}
|
|
|
|
type recommendationMetricsResp struct {
|
|
WindowDays int `json:"window_days"`
|
|
// Baseline is the control group: plays the user picked manually
|
|
// (source IS NULL). Surfaces are judged as deltas against it; nil
|
|
// when the window holds no manual plays.
|
|
Baseline *surfaceMetric `json:"baseline"`
|
|
Groups []surfaceGroup `json:"groups"`
|
|
}
|
|
|
|
// recFamily is the bucketing target for a raw play_events.source value.
|
|
type recFamily struct {
|
|
key string
|
|
label string
|
|
intent string
|
|
}
|
|
|
|
// bucketRecSource maps a raw client-stamped source string to its stable
|
|
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
|
|
// their family so the table stays readable at any library size.
|
|
func bucketRecSource(src string) recFamily {
|
|
switch {
|
|
case src == "for_you":
|
|
return recFamily{"for_you", "For You", intentGoTo}
|
|
case src == "songs_like_artist":
|
|
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
|
case src == "genre_mix":
|
|
return recFamily{"genre_mix", "Genre mixes", intentGoTo}
|
|
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
|
return recFamily{"radio", "Radio", intentGoTo}
|
|
case src == "discover":
|
|
return recFamily{"discover", "Discover", intentDiscovery}
|
|
case src == "deep_cuts":
|
|
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
|
|
case src == "rediscover":
|
|
return recFamily{"rediscover", "Rediscover", intentDiscovery}
|
|
case src == "new_for_you":
|
|
return recFamily{"new_for_you", "New for you", intentDiscovery}
|
|
case src == "on_this_day":
|
|
return recFamily{"on_this_day", "On this day", intentDiscovery}
|
|
case src == "first_listens":
|
|
return recFamily{"first_listens", "First listens", intentDiscovery}
|
|
case strings.HasPrefix(src, "album:"):
|
|
return recFamily{"direct_album", "Album plays", intentDirect}
|
|
case strings.HasPrefix(src, "artist:"):
|
|
return recFamily{"direct_artist", "Artist plays", intentDirect}
|
|
case strings.HasPrefix(src, "offline:"):
|
|
return recFamily{"offline", "Offline pools", intentDirect}
|
|
case strings.HasPrefix(src, "home:"):
|
|
return recFamily{"home", "Home sections", intentDirect}
|
|
case src == "history":
|
|
return recFamily{"history", "History", intentDirect}
|
|
default:
|
|
return recFamily{"other", "Other", intentDirect}
|
|
}
|
|
}
|
|
|
|
// familyAccum merges raw source rows into one family, carrying the
|
|
// completion sample count so the merged average stays play-weighted.
|
|
type familyAccum struct {
|
|
fam recFamily
|
|
plays int64
|
|
skips int64
|
|
completionN int64
|
|
// completionSum is avg*count re-expanded, so merging N raw rows
|
|
// reduces to a single weighted division at the end.
|
|
completionSum float64
|
|
// completionSqSum is the sum of squared completion ratios, which is what
|
|
// makes the variance mergeable across raw source rows. Standard
|
|
// deviations cannot be combined; sums of squares add exactly.
|
|
completionSqSum float64
|
|
}
|
|
|
|
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
|
a.plays += row.Plays
|
|
a.skips += row.Skips
|
|
a.completionN += row.CompletionN
|
|
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
|
a.completionSqSum += row.CompletionSqsum
|
|
}
|
|
|
|
func (a *familyAccum) metric() surfaceMetric {
|
|
m := surfaceMetric{
|
|
Key: a.fam.key,
|
|
Label: a.fam.label,
|
|
Plays: a.plays,
|
|
Skips: a.skips,
|
|
LowConfidence: a.plays < recMetricsLowVolume,
|
|
}
|
|
if a.plays > 0 {
|
|
m.SkipRate = float64(a.skips) / float64(a.plays)
|
|
}
|
|
if a.completionN > 0 {
|
|
m.AvgCompletion = a.completionSum / float64(a.completionN)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// completionVariance is the sample variance of this family's completion ratios.
|
|
func (a *familyAccum) completionVariance() float64 {
|
|
return sampleVariance(a.completionSum, a.completionSqSum, a.completionN)
|
|
}
|
|
|
|
// applyDeltas attaches baseline-relative deltas + margins to a metric.
|
|
// Split out so every row — parent surfaces and breakdown rows alike — goes
|
|
// through the identical arithmetic; a breakdown arm is exactly where the old
|
|
// card was most misleading, because those are the thinnest samples on screen.
|
|
func applyDeltas(m *surfaceMetric, acc *familyAccum, baseline *familyAccum) {
|
|
if baseline == nil || baseline.plays == 0 {
|
|
return
|
|
}
|
|
m.SkipDelta = proportionDelta(
|
|
m.SkipRate, acc.plays,
|
|
float64(baseline.skips)/float64(baseline.plays), baseline.plays,
|
|
)
|
|
baseMean := 0.0
|
|
if baseline.completionN > 0 {
|
|
baseMean = baseline.completionSum / float64(baseline.completionN)
|
|
}
|
|
m.CompletionDelta = meanDelta(
|
|
m.AvgCompletion, acc.completionVariance(), acc.completionN,
|
|
baseMean, baseline.completionVariance(), baseline.completionN,
|
|
)
|
|
}
|
|
|
|
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
|
// Bucketed per-surface-family outcomes for the caller over the last `days`
|
|
// (default 30, capped at 365), grouped by surface intent and anchored by the
|
|
// manual-plays baseline so the numbers are judgeable, not just observable.
|
|
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
|
caller, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
days, ok := parseMetricsDays(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
rows, err := dbq.New(h.pool).RecommendationSourceMetricsForUser(r.Context(),
|
|
dbq.RecommendationSourceMetricsForUserParams{UserID: caller.ID, Column2: float64(days)})
|
|
if err != nil {
|
|
h.logger.Error("api: recommendation metrics", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
|
}
|
|
|
|
// pickKindLabels is the display vocabulary for play_events.pick_kind
|
|
// values (mirrors the CHECK in migration 0041). Every system mix that
|
|
// stamps provenance gets its breakdown from this one map — adding a
|
|
// stamping mix needs no metrics change.
|
|
var pickKindLabels = map[string]string{
|
|
"taste": "Taste picks",
|
|
"fresh": "Fresh picks",
|
|
"dormant": "Dormant artists",
|
|
"taste_unheard": "Taste-matched",
|
|
"cross_user": "Liked by others",
|
|
"random": "Random unheard",
|
|
"tier1": "Tier 1 (exact)",
|
|
"tier2": "Tier 2 (relaxed)",
|
|
"tier3": "Tier 3 (stretched)",
|
|
}
|
|
|
|
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
|
|
// pick_kind — plays recorded before the mix stamped provenance, or
|
|
// whose track had rotated out of the snapshot at ingestion) renders
|
|
// last, kept visible so the parent row's sums stay transparent instead
|
|
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
|
|
// vocabulary, so iterating the list is exhaustive.
|
|
var pickKindOrder = []string{
|
|
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
|
|
"tier1", "tier2", "tier3", "",
|
|
}
|
|
|
|
// pickKindFamily derives the sub-family for one (family, pick_kind)
|
|
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
|
|
func pickKindFamily(parent recFamily, kind string) recFamily {
|
|
if kind == "" {
|
|
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
|
|
}
|
|
label, ok := pickKindLabels[kind]
|
|
if !ok {
|
|
label = kind
|
|
}
|
|
return recFamily{parent.key + "_" + kind, label, parent.intent}
|
|
}
|
|
|
|
// pickKindBreakdown folds a family's per-pick-kind accums into its
|
|
// Breakdown rows. Attached only when at least one attributed play
|
|
// exists — an all-unattributed breakdown would just repeat the parent
|
|
// row, and families that never stamp (radio, direct plays) stay flat.
|
|
func pickKindBreakdown(picks map[string]*familyAccum, baseline *familyAccum) []surfaceMetric {
|
|
attributed := int64(0)
|
|
for kind, acc := range picks {
|
|
if kind != "" {
|
|
attributed += acc.plays
|
|
}
|
|
}
|
|
if attributed == 0 {
|
|
return nil
|
|
}
|
|
out := make([]surfaceMetric, 0, len(picks))
|
|
for _, kind := range pickKindOrder {
|
|
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
|
m := acc.metric()
|
|
applyDeltas(&m, acc, baseline)
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bucketMetricsResponse folds the raw per-source rows into the grouped,
|
|
// baseline-anchored response shape. Split from the handler for pure-unit
|
|
// testability.
|
|
func bucketMetricsResponse(
|
|
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
|
|
) recommendationMetricsResp {
|
|
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
|
families := map[string]*familyAccum{}
|
|
picks := map[string]map[string]*familyAccum{}
|
|
for _, row := range rows {
|
|
if row.Source == nil || *row.Source == "" {
|
|
baseline.add(row)
|
|
continue
|
|
}
|
|
fam := bucketRecSource(*row.Source)
|
|
acc, exists := families[fam.key]
|
|
if !exists {
|
|
acc = &familyAccum{fam: fam}
|
|
families[fam.key] = acc
|
|
}
|
|
acc.add(row)
|
|
// Accumulate the pick-kind population unconditionally; families
|
|
// that never stamp end up all-unattributed and get no breakdown.
|
|
kind := ""
|
|
if row.PickKind != nil {
|
|
kind = *row.PickKind
|
|
}
|
|
byKind, ok := picks[fam.key]
|
|
if !ok {
|
|
byKind = map[string]*familyAccum{}
|
|
picks[fam.key] = byKind
|
|
}
|
|
pick, ok := byKind[kind]
|
|
if !ok {
|
|
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
|
|
byKind[kind] = pick
|
|
}
|
|
pick.add(row)
|
|
}
|
|
|
|
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
|
if baseline.plays > 0 {
|
|
m := baseline.metric()
|
|
resp.Baseline = &m
|
|
}
|
|
for _, g := range []struct{ intent, label string }{
|
|
{intentGoTo, "Go-to surfaces"},
|
|
{intentDiscovery, "Discovery mixes"},
|
|
{intentDirect, "Direct plays"},
|
|
} {
|
|
group := surfaceGroup{Intent: g.intent, Label: g.label}
|
|
for _, acc := range families {
|
|
if acc.fam.intent == g.intent {
|
|
m := acc.metric()
|
|
applyDeltas(&m, acc, baseline)
|
|
m.Breakdown = pickKindBreakdown(picks[acc.fam.key], baseline)
|
|
group.Surfaces = append(group.Surfaces, m)
|
|
}
|
|
}
|
|
if len(group.Surfaces) == 0 {
|
|
continue
|
|
}
|
|
sort.Slice(group.Surfaces, func(i, j int) bool {
|
|
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
|
|
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
|
|
}
|
|
return group.Surfaces[i].Key < group.Surfaces[j].Key
|
|
})
|
|
resp.Groups = append(resp.Groups, group)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
|
// Writes a 400 and returns ok=false on a malformed value.
|
|
func parseMetricsDays(w http.ResponseWriter, r *http.Request) (int, bool) {
|
|
v := r.URL.Query().Get("days")
|
|
if v == "" {
|
|
return recMetricsDefaultDays, true
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 {
|
|
writeErr(w, apierror.BadRequest("bad_request", "invalid days"))
|
|
return 0, false
|
|
}
|
|
if n > recMetricsMaxDays {
|
|
n = recMetricsMaxDays
|
|
}
|
|
return n, true
|
|
}
|