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

This commit was merged in pull request #96.
This commit is contained in:
2026-06-12 01:10:09 -04:00
19 changed files with 771 additions and 12 deletions
+1
View File
@@ -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)
+94
View File
@@ -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)
}
}
+21 -2
View File
@@ -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
}
+148
View File
@@ -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,
+19 -2
View File
@@ -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;
+59
View File
@@ -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;
+4
View File
@@ -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,
}
}
+7 -1
View File
@@ -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",
+6
View File
@@ -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]
+8 -7
View File
@@ -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")
+46
View File
@@ -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;
}
+46
View File
@@ -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');
+10
View File
@@ -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,