Merge PR #96: recommendation batch (You-might-like fallback + taste 2b + observability)
test-go / test (push) Successful in 41s
test-web / test (push) Successful in 43s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
test-go / test (push) Successful in 41s
test-web / test (push) Successful in 43s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
This commit was merged in pull request #96.
This commit is contained in:
@@ -74,6 +74,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
authed.Get("/me", h.handleGetMe)
|
||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
||||
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
||||
authed.Get("/me/history", h.handleGetMyHistory)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func newMetricsRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||
return r
|
||||
}
|
||||
|
||||
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
||||
// skip flag. A nil source inserts NULL (library/radio play).
|
||||
func seedSourcedPlay(
|
||||
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||
source *string, completion float64, skipped bool,
|
||||
) {
|
||||
t.Helper()
|
||||
if _, err := h.pool.Exec(context.Background(),
|
||||
`INSERT INTO play_events
|
||||
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
|
||||
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
|
||||
userID, trackID, sessionID, source, completion, skipped); err != nil {
|
||||
t.Fatalf("seed sourced play: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "metrics", "pw", false)
|
||||
artist := seedArtist(t, pool, "MetricArtist")
|
||||
album := seedAlbum(t, pool, artist.ID, "MetricAlbum", 2020)
|
||||
tk := seedTrack(t, pool, album.ID, artist.ID, "MetricTrack", 1, 200000)
|
||||
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||
|
||||
forYou := "for_you"
|
||||
discover := "discover"
|
||||
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
||||
// discover: 1 play.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
||||
// library play (NULL source) — must be excluded.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var resp recommendationMetricsResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.WindowDays != recMetricsDefaultDays {
|
||||
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
||||
}
|
||||
bySource := map[string]recommendationMetric{}
|
||||
for _, m := range resp.Sources {
|
||||
bySource[m.Source] = m
|
||||
}
|
||||
if _, present := bySource[""]; present {
|
||||
t.Error("NULL-source (library) plays should be excluded")
|
||||
}
|
||||
fy, ok := bySource["for_you"]
|
||||
if !ok {
|
||||
t.Fatal("for_you metrics missing")
|
||||
}
|
||||
if fy.Plays != 3 || fy.Skips != 1 {
|
||||
t.Errorf("for_you plays/skips = %d/%d, want 3/1", fy.Plays, fy.Skips)
|
||||
}
|
||||
if fy.SkipRate < 0.32 || fy.SkipRate > 0.34 {
|
||||
t.Errorf("for_you skip_rate = %.3f, want ~0.333", fy.SkipRate)
|
||||
}
|
||||
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
||||
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
||||
}
|
||||
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
||||
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
||||
}
|
||||
}
|
||||
@@ -737,6 +737,17 @@ likes_overlap AS (
|
||||
ORDER BY random()
|
||||
LIMIT $8
|
||||
),
|
||||
taste_overlap AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM taste_profile_artists tpa
|
||||
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||
WHERE tpa.user_id = $1
|
||||
AND tpa.weight > 0
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
LIMIT $10
|
||||
),
|
||||
random_fill AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -747,6 +758,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM similar_artists
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -763,6 +775,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
@@ -790,6 +803,7 @@ type LoadRadioCandidatesV2Params struct {
|
||||
Limit_3 int32
|
||||
Limit_4 int32
|
||||
Limit_5 int32
|
||||
Limit_6 int32
|
||||
}
|
||||
|
||||
type LoadRadioCandidatesV2Row struct {
|
||||
@@ -801,11 +815,15 @@ type LoadRadioCandidatesV2Row struct {
|
||||
SimilarityScore interface{}
|
||||
}
|
||||
|
||||
// M4c: similarity-driven candidate pool. 5-way UNION:
|
||||
// M4c: similarity-driven candidate pool. 6-way UNION:
|
||||
//
|
||||
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
||||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||
// enter the pool even when the similarity/random arms miss them; scored
|
||||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||
//
|
||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||
@@ -819,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
arg.Limit_3,
|
||||
arg.Limit_4,
|
||||
arg.Limit_5,
|
||||
arg.Limit_6,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: recommendation_metrics.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
|
||||
|
||||
SELECT
|
||||
pe.source,
|
||||
count(*)::bigint AS plays,
|
||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||
COALESCE(
|
||||
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
||||
0)::float8 AS avg_completion
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.source IS NOT NULL
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
GROUP BY pe.source
|
||||
ORDER BY plays DESC
|
||||
`
|
||||
|
||||
type RecommendationSourceMetricsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Column2 float64
|
||||
}
|
||||
|
||||
type RecommendationSourceMetricsForUserRow struct {
|
||||
Source *string
|
||||
Plays int64
|
||||
Skips int64
|
||||
AvgCompletion float64
|
||||
}
|
||||
|
||||
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||
// operator can see whether each recommendation surface is landing and tune the
|
||||
// taste weights. Source is stamped on play_events when a play is launched from
|
||||
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
||||
// NULL for library / radio / user-playlist plays, which are excluded here.
|
||||
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||
// mean completion ratio over plays that recorded one (0 when none did).
|
||||
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RecommendationSourceMetricsForUserRow
|
||||
for rows.Next() {
|
||||
var i RecommendationSourceMetricsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Source,
|
||||
&i.Plays,
|
||||
&i.Skips,
|
||||
&i.AvgCompletion,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -91,6 +91,70 @@ func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMig
|
||||
return err
|
||||
}
|
||||
|
||||
const listYouMightLikeAlbumFallbackForUser = `-- name: ListYouMightLikeAlbumFallbackForUser :many
|
||||
WITH liked_album_ids AS (
|
||||
SELECT likb.album_id AS album_id
|
||||
FROM general_likes_albums likb
|
||||
WHERE likb.user_id = $1
|
||||
UNION
|
||||
SELECT trk.album_id AS album_id
|
||||
FROM general_likes likt
|
||||
JOIN tracks trk ON trk.id = likt.track_id
|
||||
WHERE likt.user_id = $1
|
||||
)
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||
FROM liked_album_ids lai
|
||||
JOIN albums ON albums.id = lai.album_id
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
ORDER BY md5(albums.id::text || current_date::text)
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListYouMightLikeAlbumFallbackForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListYouMightLikeAlbumFallbackForUserRow struct {
|
||||
Album Album
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
|
||||
// the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
|
||||
func (q *Queries) ListYouMightLikeAlbumFallbackForUser(ctx context.Context, arg ListYouMightLikeAlbumFallbackForUserParams) ([]ListYouMightLikeAlbumFallbackForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listYouMightLikeAlbumFallbackForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListYouMightLikeAlbumFallbackForUserRow
|
||||
for rows.Next() {
|
||||
var i ListYouMightLikeAlbumFallbackForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Album.ID,
|
||||
&i.Album.Title,
|
||||
&i.Album.SortTitle,
|
||||
&i.Album.ArtistID,
|
||||
&i.Album.ReleaseDate,
|
||||
&i.Album.Mbid,
|
||||
&i.Album.CoverArtPath,
|
||||
&i.Album.CreatedAt,
|
||||
&i.Album.UpdatedAt,
|
||||
&i.Album.CoverArtSource,
|
||||
&i.Album.CoverArtSourcesVersion,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||
FROM you_might_like_albums yml
|
||||
@@ -149,6 +213,90 @@ func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYou
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listYouMightLikeArtistFallbackForUser = `-- name: ListYouMightLikeArtistFallbackForUser :many
|
||||
WITH liked_artist_ids AS (
|
||||
SELECT lika.artist_id AS artist_id
|
||||
FROM general_likes_artists lika
|
||||
WHERE lika.user_id = $1
|
||||
UNION
|
||||
SELECT alb.artist_id AS artist_id
|
||||
FROM general_likes_albums likb
|
||||
JOIN albums alb ON alb.id = likb.album_id
|
||||
WHERE likb.user_id = $1
|
||||
UNION
|
||||
SELECT trk.artist_id AS artist_id
|
||||
FROM general_likes likt
|
||||
JOIN tracks trk ON trk.id = likt.track_id
|
||||
WHERE likt.user_id = $1
|
||||
)
|
||||
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM liked_artist_ids lai
|
||||
JOIN artists ON artists.id = lai.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
ORDER BY md5(artists.id::text || current_date::text)
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListYouMightLikeArtistFallbackForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListYouMightLikeArtistFallbackForUserRow struct {
|
||||
Artist Artist
|
||||
CoverAlbumID pgtype.UUID
|
||||
AlbumCount int64
|
||||
}
|
||||
|
||||
// Broad "artists you've shown affinity for" pool to fill a thin
|
||||
// You-might-like artists row: explicitly liked artists PLUS the artists of
|
||||
// liked albums and liked tracks. Most users like albums/tracks far more than
|
||||
// they like artists explicitly, so the union is much deeper than
|
||||
// general_likes_artists alone. Daily-stable order; same projection as
|
||||
// ListYouMightLikeArtistsForUser so the Go layer reuses it directly.
|
||||
func (q *Queries) ListYouMightLikeArtistFallbackForUser(ctx context.Context, arg ListYouMightLikeArtistFallbackForUserParams) ([]ListYouMightLikeArtistFallbackForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listYouMightLikeArtistFallbackForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListYouMightLikeArtistFallbackForUserRow
|
||||
for rows.Next() {
|
||||
var i ListYouMightLikeArtistFallbackForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Artist.ID,
|
||||
&i.Artist.Name,
|
||||
&i.Artist.SortName,
|
||||
&i.Artist.Mbid,
|
||||
&i.Artist.CreatedAt,
|
||||
&i.Artist.UpdatedAt,
|
||||
&i.Artist.ArtistThumbPath,
|
||||
&i.Artist.ArtistFanartPath,
|
||||
&i.Artist.ArtistArtSource,
|
||||
&i.Artist.ArtistArtSourcesVersion,
|
||||
&i.CoverAlbumID,
|
||||
&i.AlbumCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many
|
||||
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
|
||||
@@ -33,10 +33,14 @@ WHERE t.id <> $2
|
||||
);
|
||||
|
||||
-- name: LoadRadioCandidatesV2 :many
|
||||
-- M4c: similarity-driven candidate pool. 5-way UNION:
|
||||
-- M4c: similarity-driven candidate pool. 6-way UNION:
|
||||
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
||||
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
||||
-- enter the pool even when the similarity/random arms miss them; scored
|
||||
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
|
||||
WITH
|
||||
@@ -109,6 +113,17 @@ likes_overlap AS (
|
||||
ORDER BY random()
|
||||
LIMIT $8
|
||||
),
|
||||
taste_overlap AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM taste_profile_artists tpa
|
||||
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||
WHERE tpa.user_id = $1
|
||||
AND tpa.weight > 0
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
LIMIT $10
|
||||
),
|
||||
random_fill AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -119,6 +134,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM similar_artists
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -135,6 +151,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||
-- operator can see whether each recommendation surface is landing and tune the
|
||||
-- taste weights. Source is stamped on play_events when a play is launched from
|
||||
-- a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
||||
-- NULL for library / radio / user-playlist plays, which are excluded here.
|
||||
|
||||
-- name: RecommendationSourceMetricsForUser :many
|
||||
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||
-- mean completion ratio over plays that recorded one (0 when none did).
|
||||
SELECT
|
||||
pe.source,
|
||||
count(*)::bigint AS plays,
|
||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||
COALESCE(
|
||||
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
||||
0)::float8 AS avg_completion
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.source IS NOT NULL
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
GROUP BY pe.source
|
||||
ORDER BY plays DESC;
|
||||
@@ -64,3 +64,62 @@ LEFT JOIN LATERAL (
|
||||
WHERE yml.user_id = $1
|
||||
ORDER BY yml.rank
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListYouMightLikeArtistFallbackForUser :many
|
||||
-- Broad "artists you've shown affinity for" pool to fill a thin
|
||||
-- You-might-like artists row: explicitly liked artists PLUS the artists of
|
||||
-- liked albums and liked tracks. Most users like albums/tracks far more than
|
||||
-- they like artists explicitly, so the union is much deeper than
|
||||
-- general_likes_artists alone. Daily-stable order; same projection as
|
||||
-- ListYouMightLikeArtistsForUser so the Go layer reuses it directly.
|
||||
WITH liked_artist_ids AS (
|
||||
SELECT lika.artist_id AS artist_id
|
||||
FROM general_likes_artists lika
|
||||
WHERE lika.user_id = $1
|
||||
UNION
|
||||
SELECT alb.artist_id AS artist_id
|
||||
FROM general_likes_albums likb
|
||||
JOIN albums alb ON alb.id = likb.album_id
|
||||
WHERE likb.user_id = $1
|
||||
UNION
|
||||
SELECT trk.artist_id AS artist_id
|
||||
FROM general_likes likt
|
||||
JOIN tracks trk ON trk.id = likt.track_id
|
||||
WHERE likt.user_id = $1
|
||||
)
|
||||
SELECT sqlc.embed(artists),
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM liked_artist_ids lai
|
||||
JOIN artists ON artists.id = lai.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
ORDER BY md5(artists.id::text || current_date::text)
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListYouMightLikeAlbumFallbackForUser :many
|
||||
-- Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
|
||||
-- the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
|
||||
WITH liked_album_ids AS (
|
||||
SELECT likb.album_id AS album_id
|
||||
FROM general_likes_albums likb
|
||||
WHERE likb.user_id = $1
|
||||
UNION
|
||||
SELECT trk.album_id AS album_id
|
||||
FROM general_likes likt
|
||||
JOIN tracks trk ON trk.id = likt.track_id
|
||||
WHERE likt.user_id = $1
|
||||
)
|
||||
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||
FROM liked_album_ids lai
|
||||
JOIN albums ON albums.id = lai.album_id
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
ORDER BY md5(albums.id::text || current_date::text)
|
||||
LIMIT $2;
|
||||
|
||||
@@ -329,6 +329,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
||||
TagOverlap: 60,
|
||||
LikesOverlap: 40,
|
||||
RandomFill: 150,
|
||||
// For-You / You-might-like are the taste-driven surfaces, so pull a
|
||||
// deep slice of the user's top taste-profile artists into the pool
|
||||
// (#796 phase 2b). Empty for cold-start users (no profile yet).
|
||||
TasteOverlap: 80,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,9 +89,15 @@ func buildYouMightLike(
|
||||
}
|
||||
|
||||
zeroVec := recommendation.SessionVector{Seed: true}
|
||||
// You-might-like surfaces in-library artists the user does NOT actively
|
||||
// engage with, so it deliberately skips the taste_overlap arm — that arm
|
||||
// pulls top-taste (mostly already-played) artists, which would crowd the
|
||||
// pool with entities the read-time dedup then strips. For-You/radio keep it.
|
||||
ymlLimits := systemForYouSourceLimits()
|
||||
ymlLimits.TasteOverlap = 0
|
||||
cands, err := recommendation.LoadCandidatesFromSimilarity(
|
||||
ctx, q, userID, seed, 1, zeroVec,
|
||||
[]pgtype.UUID{seed}, systemForYouSourceLimits(),
|
||||
[]pgtype.UUID{seed}, ymlLimits,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn("you-might-like: candidate load failed; skipping",
|
||||
|
||||
@@ -72,6 +72,10 @@ type CandidateSourceLimits struct {
|
||||
TagOverlap int
|
||||
LikesOverlap int
|
||||
RandomFill int
|
||||
// TasteOverlap (#796 phase 2b): tracks by the user's top positively-
|
||||
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
||||
// users have an empty profile, so it contributes nothing anyway).
|
||||
TasteOverlap int
|
||||
}
|
||||
|
||||
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
||||
@@ -82,6 +86,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
TagOverlap: 20,
|
||||
LikesOverlap: 20,
|
||||
RandomFill: 30,
|
||||
TasteOverlap: 20,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +118,7 @@ func LoadCandidatesFromSimilarity(
|
||||
Limit_3: int32(limits.TagOverlap),
|
||||
Limit_4: int32(limits.LikesOverlap),
|
||||
Limit_5: int32(limits.RandomFill),
|
||||
Limit_6: int32(limits.TasteOverlap),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -262,6 +262,61 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by
|
||||
// a positively-weighted taste-profile artist enters the pool via taste_overlap
|
||||
// even with every other arm disabled; a negatively-weighted artist's track does
|
||||
// not (the WHERE weight > 0 filter).
|
||||
func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) {
|
||||
f := newFixture(t, 2) // seed + 1 other, both by the fixture artist
|
||||
seed := f.tracks[0]
|
||||
target := f.tracks[1]
|
||||
ctx := context.Background()
|
||||
|
||||
// Fixture artist gets a positive taste weight.
|
||||
if _, err := f.pool.Exec(ctx,
|
||||
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 5.0)`,
|
||||
f.user, seed.ArtistID); err != nil {
|
||||
t.Fatalf("insert taste (positive): %v", err)
|
||||
}
|
||||
|
||||
// A second artist with a NEGATIVE weight — its track must be excluded.
|
||||
negArtist, _ := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "NegArtist", SortName: "NegArtist"})
|
||||
negAlbum, _ := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "NegAlbum", SortTitle: "NegAlbum", ArtistID: negArtist.ID})
|
||||
negTrack, _ := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||
Title: "NegTrack", AlbumID: negAlbum.ID, ArtistID: negArtist.ID,
|
||||
FilePath: "/tmp/neg.flac", DurationMs: 180_000,
|
||||
})
|
||||
if _, err := f.pool.Exec(ctx,
|
||||
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, -3.0)`,
|
||||
f.user, negArtist.ID); err != nil {
|
||||
t.Fatalf("insert taste (negative): %v", err)
|
||||
}
|
||||
|
||||
// Only the taste_overlap arm is enabled.
|
||||
limits := CandidateSourceLimits{TasteOverlap: 10}
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
var sawTarget, sawNeg bool
|
||||
for _, c := range got {
|
||||
switch c.Track.ID {
|
||||
case target.ID:
|
||||
sawTarget = true
|
||||
case negTrack.ID:
|
||||
sawNeg = true
|
||||
}
|
||||
}
|
||||
if !sawTarget {
|
||||
t.Error("positive-taste-artist track missing (taste_overlap arm didn't contribute)")
|
||||
}
|
||||
if sawNeg {
|
||||
t.Error("negative-taste-artist track present (weight > 0 filter failed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
||||
f := newFixture(t, 1) // just the seed
|
||||
seed := f.tracks[0]
|
||||
|
||||
@@ -199,11 +199,12 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
||||
const youMightLikeFallbackFetch = 100
|
||||
|
||||
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
|
||||
// user's liked artists (daily-stable sample), excluding what's already shown
|
||||
// on Home (the section itself, Rediscover, Most/Last Played) so the fallback
|
||||
// neither duplicates a tile nor suggests an actively-played artist. The liked
|
||||
// pool dwarfs the similarity roll-up, so the same exclusions still fill it.
|
||||
// Best-effort: a query error leaves the section as-is.
|
||||
// artists the user has shown affinity for — explicit artist-likes PLUS the
|
||||
// artists of liked albums and liked tracks (most users like albums/tracks far
|
||||
// more than artists, so the broad pool is what actually fills the row).
|
||||
// Excludes what's already shown on Home (the section itself, Rediscover,
|
||||
// Most/Last Played) so the fallback neither duplicates a tile nor suggests an
|
||||
// actively-played artist. Best-effort: a query error leaves the section as-is.
|
||||
func topUpYouMightLikeArtists(
|
||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
||||
) []dbq.ListYouMightLikeArtistsForUserRow {
|
||||
@@ -218,7 +219,7 @@ func topUpYouMightLikeArtists(
|
||||
for _, r := range result {
|
||||
excluded[r.Artist.ID] = struct{}{}
|
||||
}
|
||||
fb, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{
|
||||
fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{
|
||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -249,7 +250,7 @@ func topUpYouMightLikeAlbums(
|
||||
for _, r := range result {
|
||||
excluded[r.Album.ID] = struct{}{}
|
||||
}
|
||||
fb, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{
|
||||
fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{
|
||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -48,6 +48,40 @@ func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeData_YouMightLike_FallbackFromLikedTrackArtists: the broad fallback
|
||||
// must surface the artists of LIKED TRACKS even with no explicit artist-likes
|
||||
// (most users like tracks/albums, rarely artists). 12 liked tracks by distinct
|
||||
// artists, no artist-likes, no plays → the artist row fills from them.
|
||||
func TestHomeData_YouMightLike_FallbackFromLikedTrackArtists(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "home-yml-trackartists")
|
||||
fromLiked := make(map[pgtype.UUID]bool, 12)
|
||||
for i := 0; i < 12; i++ {
|
||||
a := seedArtist(t, pool, fmt.Sprintf("TrkArtist%02d", i), "")
|
||||
al := seedAlbumForArtist(t, pool, a.ID, fmt.Sprintf("Alb%02d", i))
|
||||
tr := seedTrackOnAlbum(t, pool, al.ID, a.ID, fmt.Sprintf("Trk%02d", i))
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, tr.ID); err != nil {
|
||||
t.Fatalf("like track: %v", err)
|
||||
}
|
||||
fromLiked[a.ID] = true
|
||||
}
|
||||
|
||||
got, err := HomeData(context.Background(), pool, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("HomeData: %v", err)
|
||||
}
|
||||
if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit {
|
||||
t.Fatalf("you-might-like artists = %d, want %d (from liked-track artists)",
|
||||
len(got.YouMightLikeArtists), HomeYouMightLikeLimit)
|
||||
}
|
||||
for _, r := range got.YouMightLikeArtists {
|
||||
if !fromLiked[r.Artist.ID] {
|
||||
t.Errorf("artist %s not from the liked-track pool", r.Artist.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "home-newuser")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
|
||||
// Mirrors internal/api/me_recommendation_metrics.go.
|
||||
export type RecommendationMetric = {
|
||||
source: string;
|
||||
plays: number;
|
||||
skips: number;
|
||||
skip_rate: number;
|
||||
avg_completion: number;
|
||||
};
|
||||
|
||||
export type RecommendationMetrics = {
|
||||
window_days: number;
|
||||
sources: RecommendationMetric[];
|
||||
};
|
||||
|
||||
export function getRecommendationMetrics(): Promise<RecommendationMetrics> {
|
||||
return api.get<RecommendationMetrics>('/api/me/recommendation-metrics');
|
||||
}
|
||||
|
||||
export const REC_METRICS_QUERY_KEY = ['settings', 'recommendation-metrics'] as const;
|
||||
|
||||
export function createRecommendationMetricsQuery() {
|
||||
return createQuery({
|
||||
queryKey: REC_METRICS_QUERY_KEY,
|
||||
queryFn: getRecommendationMetrics,
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
// Friendly labels for the system-playlist source keys (play_events.source).
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
for_you: 'For You',
|
||||
discover: 'Discover',
|
||||
deep_cuts: 'Deep cuts',
|
||||
rediscover: 'Rediscover',
|
||||
new_for_you: 'New for you',
|
||||
on_this_day: 'On this day',
|
||||
first_listens: 'First listens',
|
||||
songs_like_artist: 'Songs like…'
|
||||
};
|
||||
|
||||
export function sourceLabel(source: string): string {
|
||||
return SOURCE_LABELS[source] ?? source;
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
createEnabledMutation,
|
||||
type LBStatus
|
||||
} from '$lib/api/listenbrainz';
|
||||
import {
|
||||
createRecommendationMetricsQuery,
|
||||
sourceLabel,
|
||||
type RecommendationMetrics
|
||||
} from '$lib/api/metrics';
|
||||
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
|
||||
import { player, setCrossfade } from '$lib/player/store.svelte';
|
||||
import {
|
||||
@@ -24,6 +29,7 @@
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
|
||||
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
|
||||
const tokenMutation = createTokenMutation(queryClient);
|
||||
const enabledMutation = createEnabledMutation(queryClient);
|
||||
|
||||
@@ -267,6 +273,46 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Recommendation metrics card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Recommendation metrics</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
How plays launched from each recommendation surface land, over the last
|
||||
{$metrics.data?.window_days ?? 30} days. Lower skip rate and higher average
|
||||
completion mean the surface is hitting.
|
||||
</p>
|
||||
{#if $metrics.isPending}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else if $metrics.isError}
|
||||
<p class="text-sm text-text-secondary">Couldn't load metrics.</p>
|
||||
{:else if $metrics.data && $metrics.data.sources.length > 0}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-text-secondary">
|
||||
<th class="py-1 font-medium">Surface</th>
|
||||
<th class="py-1 text-right font-medium">Plays</th>
|
||||
<th class="py-1 text-right font-medium">Skip rate</th>
|
||||
<th class="py-1 text-right font-medium">Avg completion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $metrics.data.sources as m (m.source)}
|
||||
<tr class="border-t border-border">
|
||||
<td class="py-1">{sourceLabel(m.source)}</td>
|
||||
<td class="py-1 text-right tabular-nums">{m.plays}</td>
|
||||
<td class="py-1 text-right tabular-nums">{(m.skip_rate * 100).toFixed(0)}%</td>
|
||||
<td class="py-1 text-right tabular-nums">{(m.avg_completion * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">
|
||||
No recommendation plays yet. Play something from For You, Discover, or a mix.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Profile card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Profile</h2>
|
||||
|
||||
@@ -25,6 +25,16 @@ vi.mock('$lib/api/listenbrainz', () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/metrics', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } });
|
||||
return () => {};
|
||||
}
|
||||
}),
|
||||
sourceLabel: (s: string) => s
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
|
||||
@@ -20,6 +20,16 @@ vi.mock('$lib/api/me', () => ({
|
||||
regenerateAPIToken: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/metrics', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } });
|
||||
return () => {};
|
||||
}
|
||||
}),
|
||||
sourceLabel: (s: string) => s
|
||||
}));
|
||||
|
||||
import SettingsPage from './+page.svelte';
|
||||
import {
|
||||
createLBStatusQuery,
|
||||
|
||||
Reference in New Issue
Block a user