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 }