feat(server): "You might like" album/artist Home rows (#790)
Surface in-library albums/artists the listener doesn't actively spin but is predicted to enjoy, derived from the same similarity + like-weighted candidate engine that powers For-You — rolled up from track scores to album/artist granularity. Built in the daily 3am BuildSystemPlaylists pass, atomic-replaced alongside the system playlists, and read back by /api/home (+ /api/home/index). Cold-start gate: skips generation entirely below 20 distinct unskipped tracks AND 5 distinct artists, so a thin profile ships empty rows rather than near-random tiles. - migration 0034: you_might_like_albums / you_might_like_artists (id+rank, CASCADE, per-user rank index). - playlists/you_might_like.go: cold-start gate + similarity roll-up (sum-of-top-3 aggregation, per-artist album cap, daily-rotating via the same userIDHash jitter as For-You) + atomic-replace persist in the tx. - recommendation/home.go: two new HomePayload sections with read-time cross-section dedup vs Most Played / Rediscover / Last Played, trimmed to 10 each. - api: you_might_like_albums / you_might_like_artists on /api/home and /api/home/index, reusing albumRefFrom / artistRefFromCovered. - tests: pure roll-up/aggregation/cap unit tests + DB-backed gate, sufficiency, and atomic-replace tests (all green vs real Postgres). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
)
|
||||
|
||||
// countYouMightLike returns the persisted row counts for a user.
|
||||
func countYouMightLike(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) (albums, artists int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM you_might_like_albums WHERE user_id = $1`, userID,
|
||||
).Scan(&albums); err != nil {
|
||||
t.Fatalf("count albums: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM you_might_like_artists WHERE user_id = $1`, userID,
|
||||
).Scan(&artists); err != nil {
|
||||
t.Fatalf("count artists: %v", err)
|
||||
}
|
||||
return albums, artists
|
||||
}
|
||||
|
||||
// TestYouMightLike_ColdStartGate: a thin profile (3 artists × 3 tracks =
|
||||
// 9 distinct tracks, below the 20-track / 5-artist gate) must produce no
|
||||
// You-might-like rows — the build ships nothing rather than near-random
|
||||
// tiles.
|
||||
func TestYouMightLike_ColdStartGate(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
u, _ := seedActiveLibrary(t, pool, "ymlcold", 3, 3)
|
||||
|
||||
if err := playlists.BuildSystemPlaylists(
|
||||
context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
|
||||
); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
albums, artists := countYouMightLike(t, pool, u.ID)
|
||||
if albums != 0 || artists != 0 {
|
||||
t.Errorf("cold-start user should have no you-might-like rows; got %d albums, %d artists",
|
||||
albums, artists)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYouMightLike_SufficientActivityPopulates: a rich profile (8 artists
|
||||
// × 4 tracks = 32 distinct tracks, clearing both gate bars) populates the
|
||||
// You-might-like tables in the daily build.
|
||||
func TestYouMightLike_SufficientActivityPopulates(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
u, _ := seedActiveLibrary(t, pool, "ymlrich", 8, 4)
|
||||
|
||||
if err := playlists.BuildSystemPlaylists(
|
||||
context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
|
||||
); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
albums, artists := countYouMightLike(t, pool, u.ID)
|
||||
if albums == 0 {
|
||||
t.Error("rich user should have you-might-like album rows; got 0")
|
||||
}
|
||||
if artists == 0 {
|
||||
t.Error("rich user should have you-might-like artist rows; got 0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestYouMightLike_AtomicReplace: a second build replaces rather than
|
||||
// appends, so counts stay bounded by the persisted depth.
|
||||
func TestYouMightLike_AtomicReplace(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
u, _ := seedActiveLibrary(t, pool, "ymlreplace", 8, 4)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := playlists.BuildSystemPlaylists(
|
||||
ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
|
||||
); err != nil {
|
||||
t.Fatalf("build %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
albums, artists := countYouMightLike(t, pool, u.ID)
|
||||
if albums > 12 {
|
||||
t.Errorf("albums = %d after two builds, want <= 12 (atomic replace)", albums)
|
||||
}
|
||||
if artists > 12 {
|
||||
t.Errorf("artists = %d after two builds, want <= 12 (atomic replace)", artists)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user