1a7515e6ea
Per-source play outcomes so the operator can see whether each recommendation
surface is landing and tune the now-operator-tunable taste weights.
Server:
- query RecommendationSourceMetricsForUser: groups the user's play_events by
source (system-playlist surface), reporting plays / skips / avg completion
over a window; NULL-source (library/radio) plays excluded.
- GET /api/me/recommendation-metrics?days=30 (default 30, capped 365) →
{window_days, sources:[{source, plays, skips, skip_rate, avg_completion}]}.
- handler test: 401 unauth; per-source aggregation + NULL-source exclusion +
skip_rate / avg_completion math.
Web:
- lib/api/metrics.ts: query + friendly source labels.
- settings page gains a "Recommendation metrics" card (table of surface / plays
/ skip rate / avg completion), with loading/error/empty states.
- settings tests mock the new query (manual subscribe-store, hoisting-safe).
Note: You-might-like plays aren't source-tagged (it's a Home row, not a system
playlist), so this covers For-You / Discover / the mixes. Tagging YML plays
would be a client follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
2.8 KiB
Go
95 lines
2.8 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
const (
|
|
recMetricsDefaultDays = 30
|
|
recMetricsMaxDays = 365
|
|
)
|
|
|
|
// recommendationMetric is one recommendation surface's outcomes.
|
|
type recommendationMetric struct {
|
|
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
|
Plays int64 `json:"plays"` // plays launched from this surface
|
|
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]
|
|
}
|
|
|
|
type recommendationMetricsResp struct {
|
|
WindowDays int `json:"window_days"`
|
|
Sources []recommendationMetric `json:"sources"`
|
|
}
|
|
|
|
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
|
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
|
// caller over the last `days` (default 30, capped at 365), so the operator can
|
|
// see which recommendation surfaces are landing and tune the taste weights.
|
|
// Only plays tagged with a system-playlist source count; library/radio plays
|
|
// (no source) are excluded.
|
|
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
|
|
}
|
|
|
|
out := recommendationMetricsResp{
|
|
WindowDays: days,
|
|
Sources: make([]recommendationMetric, 0, len(rows)),
|
|
}
|
|
for _, row := range rows {
|
|
source := ""
|
|
if row.Source != nil {
|
|
source = *row.Source
|
|
}
|
|
var skipRate float64
|
|
if row.Plays > 0 {
|
|
skipRate = float64(row.Skips) / float64(row.Plays)
|
|
}
|
|
out.Sources = append(out.Sources, recommendationMetric{
|
|
Source: source,
|
|
Plays: row.Plays,
|
|
Skips: row.Skips,
|
|
SkipRate: skipRate,
|
|
AvgCompletion: row.AvgCompletion,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// 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
|
|
}
|