fdd14ef04c
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>
100 lines
3.0 KiB
Go
100 lines
3.0 KiB
Go
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)
|
||
}
|
||
}
|