feat(playlists): For You composition v2 — multi-seed blend + weighted fresh tail
Two approved composition changes (#1269), mechanism only — the taste/fresh share stays data-decided (#1252) and pick_kind attribution is unchanged. Multi-seed blending: each day's build now seeds from up to 3 of the user's top-5 tracks (pickDailySeeds, the generalized daily shuffle) instead of one rotating anchor, so the mix spans neighborhoods within a day and stops feeling bipolar as the rotation swings between dissimilar seeds. Per-seed pools merge first-seen-deduped; the head is filled best-first under 50/30/20 per-seed quotas (60/40 for two seeds) so one neighborhood can't monopolize it, with thin-seed quota spilling best-first. Score-weighted fresh tail: the tail sample (rank 2*headN onward) was uniform — the 380th-best candidate as likely as the 101st. It now uses deterministic Efraimidis-Spirakis keys with weight halving every 50 ranks, so freshness keeps its "you'll probably enjoy this" half while still rotating daily. The retired single-seed picker's one other caller, You-might-like, moves to pickDailySeeds(n=1) — a single neighborhood per day is right for a short shelf, and the behavior note is inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -513,10 +513,10 @@ SELECT id
|
|||||||
// tier 2 (only if tiers 0+1 empty) liked tracks
|
// tier 2 (only if tiers 0+1 empty) liked tracks
|
||||||
//
|
//
|
||||||
// Returns up to 5 ids; tie-break by track_id for determinism. The
|
// Returns up to 5 ids; tie-break by track_id for determinism. The
|
||||||
// Go-side picker (pickForYouSeedForDay) rotates one per day via
|
// Go-side picker (pickDailySeeds) draws the day's seeds from these.
|
||||||
// userIDHash. Widened from a hard 7-day window, which made For-You
|
// Widened from a hard 7-day window, which made For-You disappear
|
||||||
// disappear after a week of not listening and never recover on a
|
// after a week of not listening and never recover on a self-hosted
|
||||||
// self-hosted library with sparse history.
|
// library with sparse history.
|
||||||
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||||
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -131,10 +131,10 @@ SELECT c.artist_id,
|
|||||||
-- tier 1 (only if tier 0 empty) all-time top non-skip plays
|
-- tier 1 (only if tier 0 empty) all-time top non-skip plays
|
||||||
-- tier 2 (only if tiers 0+1 empty) liked tracks
|
-- tier 2 (only if tiers 0+1 empty) liked tracks
|
||||||
-- Returns up to 5 ids; tie-break by track_id for determinism. The
|
-- Returns up to 5 ids; tie-break by track_id for determinism. The
|
||||||
-- Go-side picker (pickForYouSeedForDay) rotates one per day via
|
-- Go-side picker (pickDailySeeds) draws the day's seeds from these.
|
||||||
-- userIDHash. Widened from a hard 7-day window, which made For-You
|
-- Widened from a hard 7-day window, which made For-You disappear
|
||||||
-- disappear after a week of not listening and never recover on a
|
-- after a week of not listening and never recover on a self-hosted
|
||||||
-- self-hosted library with sparse history.
|
-- library with sparse history.
|
||||||
WITH recent AS (
|
WITH recent AS (
|
||||||
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) {
|
|||||||
makeCand(2, 11, 101, 0.9),
|
makeCand(2, 11, 101, 0.9),
|
||||||
makeCand(3, 12, 102, 0.8),
|
makeCand(3, 12, 102, 0.8),
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||||
if len(got) != 3 {
|
if len(got) != 3 {
|
||||||
t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got))
|
t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got))
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func TestPickHeadAndTail_ExactlyTotal(t *testing.T) {
|
|||||||
for i := 0; i < 7; i++ {
|
for i := 0; i < 7; i++ {
|
||||||
in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)))
|
in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)))
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||||
if len(got) != 7 {
|
if len(got) != 7 {
|
||||||
t.Errorf("len = %d, want 7 (pool == total)", len(got))
|
t.Errorf("len = %d, want 7 (pool == total)", len(got))
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) {
|
|||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||||
if len(got) != 25 {
|
if len(got) != 25 {
|
||||||
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
|
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
|
||||||
}
|
}
|
||||||
@@ -139,8 +139,8 @@ func TestPickHeadAndTail_Determinism(t *testing.T) {
|
|||||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
got1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
got1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
||||||
got2 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
got2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
||||||
if len(got1) != len(got2) {
|
if len(got1) != len(got2) {
|
||||||
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
|
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
|
||||||
}
|
}
|
||||||
@@ -161,8 +161,8 @@ func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) {
|
|||||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
day1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
day1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
||||||
day2 := pickHeadAndTail(in, testUserID, "2026-05-08", now, 20, 5)
|
day2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-08", now, 20, 5)
|
||||||
if len(day1) != 25 || len(day2) != 25 {
|
if len(day1) != 25 || len(day2) != 25 {
|
||||||
t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2))
|
t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2))
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) {
|
|||||||
sim := float64(50 - i)
|
sim := float64(50 - i)
|
||||||
in = append(in, makeCand(i+1, i+1, i+1, sim))
|
in = append(in, makeCand(i+1, i+1, i+1, sim))
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 3)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 3)
|
||||||
if len(got) != 8 {
|
if len(got) != 8 {
|
||||||
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
|
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,7 @@ func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
|
|||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||||
if len(got) != 25 {
|
if len(got) != 25 {
|
||||||
t.Fatalf("len = %d, want 25", len(got))
|
t.Fatalf("len = %d, want 25", len(got))
|
||||||
}
|
}
|
||||||
@@ -275,7 +275,7 @@ func TestPickHeadAndTail_SmallPoolAllTaste(t *testing.T) {
|
|||||||
makeCand(2, 11, 101, 0.9),
|
makeCand(2, 11, 101, 0.9),
|
||||||
makeCand(3, 12, 102, 0.8),
|
makeCand(3, 12, 102, 0.8),
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||||
for i, rc := range got {
|
for i, rc := range got {
|
||||||
if rc.PickKind != pickKindTaste {
|
if rc.PickKind != pickKindTaste {
|
||||||
t.Errorf("got[%d].PickKind = %q, want %q (fallback is all taste)",
|
t.Errorf("got[%d].PickKind = %q, want %q (fallback is all taste)",
|
||||||
@@ -293,3 +293,140 @@ func TestPickTopN_NoPickKind(t *testing.T) {
|
|||||||
t.Errorf("pickTopN PickKind = %q, want empty (persists as NULL)", got[0].PickKind)
|
t.Errorf("pickTopN PickKind = %q, want empty (persists as NULL)", got[0].PickKind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHeadQuotas(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
seeds, headN int
|
||||||
|
want []int
|
||||||
|
}{
|
||||||
|
{1, 50, []int{50}},
|
||||||
|
{2, 50, []int{30, 20}},
|
||||||
|
{3, 50, []int{25, 15, 10}},
|
||||||
|
{3, 7, []int{4, 2, 1}}, // rounding remainder → primary seed
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := headQuotas(c.seeds, c.headN)
|
||||||
|
if len(got) != len(c.want) {
|
||||||
|
t.Fatalf("headQuotas(%d, %d) len = %d, want %d", c.seeds, c.headN, len(got), len(c.want))
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for i := range got {
|
||||||
|
sum += got[i]
|
||||||
|
if got[i] != c.want[i] {
|
||||||
|
t.Errorf("headQuotas(%d, %d)[%d] = %d, want %d", c.seeds, c.headN, i, got[i], c.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sum != c.headN {
|
||||||
|
t.Errorf("headQuotas(%d, %d) sums to %d, want %d", c.seeds, c.headN, sum, c.headN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickQuotaHead_BlendsSeeds(t *testing.T) {
|
||||||
|
// Seed 0's neighborhood out-scores everything (#1269): without
|
||||||
|
// quotas the head would be all seed-0. With headN=10 and quotas
|
||||||
|
// [5,3,2], the walk takes the best 5 from seed 0, then seeds 1 and
|
||||||
|
// 2 get their guaranteed slots from deeper ranks.
|
||||||
|
in := make([]recommendation.Candidate, 0, 30)
|
||||||
|
seedOf := map[pgtype.UUID]int{}
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
c := makeCand(i+1, i+1, i+1, float64(30-i))
|
||||||
|
si := 0
|
||||||
|
if i >= 20 && i < 25 {
|
||||||
|
si = 1
|
||||||
|
} else if i >= 25 {
|
||||||
|
si = 2
|
||||||
|
}
|
||||||
|
seedOf[c.Track.ID] = si
|
||||||
|
in = append(in, c)
|
||||||
|
}
|
||||||
|
// Input is already score-descending and cap-free.
|
||||||
|
head := pickQuotaHead(in, seedOf, 3, 10)
|
||||||
|
if len(head) != 10 {
|
||||||
|
t.Fatalf("head len = %d, want 10", len(head))
|
||||||
|
}
|
||||||
|
want := []byte{1, 2, 3, 4, 5, 21, 22, 23, 26, 27}
|
||||||
|
for i, w := range want {
|
||||||
|
if head[i].Track.ID.Bytes[15] != w {
|
||||||
|
t.Errorf("head[%d] = track %d, want %d", i, head[i].Track.ID.Bytes[15], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickQuotaHead_ThinSeedSpillsBestFirst(t *testing.T) {
|
||||||
|
// Seed 1 has a single candidate; its unfilled quota goes to the
|
||||||
|
// best remaining candidates regardless of seed.
|
||||||
|
in := make([]recommendation.Candidate, 0, 12)
|
||||||
|
seedOf := map[pgtype.UUID]int{}
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
c := makeCand(i+1, i+1, i+1, float64(12-i))
|
||||||
|
si := 0
|
||||||
|
if i == 11 {
|
||||||
|
si = 1 // the worst-ranked candidate is seed 1's only one
|
||||||
|
}
|
||||||
|
seedOf[c.Track.ID] = si
|
||||||
|
in = append(in, c)
|
||||||
|
}
|
||||||
|
head := pickQuotaHead(in, seedOf, 2, 10)
|
||||||
|
if len(head) != 10 {
|
||||||
|
t.Fatalf("head len = %d, want 10", len(head))
|
||||||
|
}
|
||||||
|
// Quotas [6,4]: seed 0 fills 6 (tracks 1-6), seed 1 fills 1
|
||||||
|
// (track 12), spill tops up with tracks 7-9.
|
||||||
|
got := map[byte]bool{}
|
||||||
|
for _, c := range head {
|
||||||
|
got[c.Track.ID.Bytes[15]] = true
|
||||||
|
}
|
||||||
|
for _, w := range []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 12} {
|
||||||
|
if !got[w] {
|
||||||
|
t.Errorf("head missing track %d (spill should fill best-first)", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedTail_PrefersEarlyRanks(t *testing.T) {
|
||||||
|
// The fresh tail's sample is rank-weighted (#1269): across many
|
||||||
|
// days, the mean selected rank must sit well below the uniform
|
||||||
|
// draw's ~99.5 for a 200-deep pool.
|
||||||
|
pool := make([]recommendation.Candidate, 0, 200)
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
c := makeCand(1, i+1, i+1, 1.0)
|
||||||
|
// Distinct track IDs beyond one byte: bytes wrap at 256.
|
||||||
|
c.Track.ID.Bytes[14] = byte(i / 200)
|
||||||
|
c.Track.ID.Bytes[15] = byte(i % 200)
|
||||||
|
pool = append(pool, c)
|
||||||
|
}
|
||||||
|
rankOf := map[pgtype.UUID]int{}
|
||||||
|
for i, c := range pool {
|
||||||
|
rankOf[c.Track.ID] = i
|
||||||
|
}
|
||||||
|
totalRank, picks := 0, 0
|
||||||
|
for day := 1; day <= 28; day++ {
|
||||||
|
got := pickWeightedTail(pool, "2026-06-"+twoDigits(day), 10)
|
||||||
|
if len(got) != 10 {
|
||||||
|
t.Fatalf("day %d: len = %d, want 10", day, len(got))
|
||||||
|
}
|
||||||
|
for _, c := range got {
|
||||||
|
totalRank += rankOf[c.Track.ID]
|
||||||
|
picks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mean := float64(totalRank) / float64(picks)
|
||||||
|
if mean > 80 {
|
||||||
|
t.Errorf("mean selected rank = %.1f; want well below uniform ~99.5", mean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedTail_DeterministicWithinDay(t *testing.T) {
|
||||||
|
pool := make([]recommendation.Candidate, 0, 60)
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
pool = append(pool, makeCand(i+1, i+1, i+1, 1.0))
|
||||||
|
}
|
||||||
|
a := pickWeightedTail(pool, "2026-06-05", 8)
|
||||||
|
b := pickWeightedTail(pool, "2026-06-05", 8)
|
||||||
|
for i := range a {
|
||||||
|
if a[i].Track.ID != b[i].Track.ID {
|
||||||
|
t.Fatal("weighted tail must be deterministic within a day")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,60 +96,65 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickForYouSeedForDay rotates the chosen For-You seed across the
|
// pickDailySeeds shuffles the candidate pool daily-deterministically
|
||||||
// user's top-played candidates using userIDHash. Verifies the picker
|
// and takes up to n — For-You uses n=forYouSeedCount for its
|
||||||
// is deterministic within a day, varies across days, and degrades
|
// multi-seed blend (#1269), Songs-like uses n=3 via the
|
||||||
// gracefully when fewer than 5 candidates exist.
|
// pickSeedArtistsForDay wrapper. Verifies determinism within a day,
|
||||||
|
// variation across days, and graceful degradation on small pools.
|
||||||
|
|
||||||
func TestPickForYouSeedForDay_DeterministicWithinDay(t *testing.T) {
|
func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
seeds := []pgtype.UUID{
|
pool := []pgtype.UUID{
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
{Bytes: [16]byte{10}, Valid: true},
|
||||||
{Bytes: [16]byte{20}, Valid: true},
|
{Bytes: [16]byte{20}, Valid: true},
|
||||||
{Bytes: [16]byte{30}, Valid: true},
|
{Bytes: [16]byte{30}, Valid: true},
|
||||||
{Bytes: [16]byte{40}, Valid: true},
|
{Bytes: [16]byte{40}, Valid: true},
|
||||||
{Bytes: [16]byte{50}, Valid: true},
|
{Bytes: [16]byte{50}, Valid: true},
|
||||||
}
|
}
|
||||||
a := pickForYouSeedForDay(seeds, u, "2026-05-04")
|
a := pickDailySeeds(pool, u, "2026-05-04", 3)
|
||||||
b := pickForYouSeedForDay(seeds, u, "2026-05-04")
|
b := pickDailySeeds(pool, u, "2026-05-04", 3)
|
||||||
if a != b {
|
if len(a) != 3 || len(b) != 3 {
|
||||||
t.Fatalf("same day should pick same seed; got %v then %v", a, b)
|
t.Fatalf("lens = %d, %d; want 3, 3", len(a), len(b))
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
t.Fatalf("same day should pick same seeds; got %v then %v", a, b)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPickForYouSeedForDay_VariesAcrossDays(t *testing.T) {
|
func TestPickDailySeeds_VariesAcrossDays(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
seeds := []pgtype.UUID{
|
pool := []pgtype.UUID{
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
{Bytes: [16]byte{10}, Valid: true},
|
||||||
{Bytes: [16]byte{20}, Valid: true},
|
{Bytes: [16]byte{20}, Valid: true},
|
||||||
{Bytes: [16]byte{30}, Valid: true},
|
{Bytes: [16]byte{30}, Valid: true},
|
||||||
{Bytes: [16]byte{40}, Valid: true},
|
{Bytes: [16]byte{40}, Valid: true},
|
||||||
{Bytes: [16]byte{50}, Valid: true},
|
{Bytes: [16]byte{50}, Valid: true},
|
||||||
}
|
}
|
||||||
picks := map[[16]byte]bool{}
|
firstPicks := map[[16]byte]bool{}
|
||||||
for i := 1; i <= 30; i++ {
|
for i := 1; i <= 30; i++ {
|
||||||
date := "2026-05-" + twoDigits(i)
|
date := "2026-05-" + twoDigits(i)
|
||||||
picks[pickForYouSeedForDay(seeds, u, date).Bytes] = true
|
firstPicks[pickDailySeeds(pool, u, date, 3)[0].Bytes] = true
|
||||||
}
|
}
|
||||||
if len(picks) < 2 {
|
if len(firstPicks) < 2 {
|
||||||
t.Errorf("expected >=2 distinct seeds across 30 days; got %d", len(picks))
|
t.Errorf("expected >=2 distinct lead seeds across 30 days; got %d", len(firstPicks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPickForYouSeedForDay_SingleCandidate(t *testing.T) {
|
func TestPickDailySeeds_SmallPool(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true}
|
only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true}
|
||||||
got := pickForYouSeedForDay([]pgtype.UUID{only}, u, "2026-05-04")
|
got := pickDailySeeds([]pgtype.UUID{only}, u, "2026-05-04", 3)
|
||||||
if got != only {
|
if len(got) != 1 || got[0] != only {
|
||||||
t.Errorf("single-seed pool should return that seed; got %v", got)
|
t.Errorf("single-entry pool should return just that entry; got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPickForYouSeedForDay_EmptyPool(t *testing.T) {
|
func TestPickDailySeeds_EmptyPool(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
got := pickForYouSeedForDay(nil, u, "2026-05-04")
|
if got := pickDailySeeds(nil, u, "2026-05-04", 3); len(got) != 0 {
|
||||||
if got.Valid {
|
t.Errorf("empty pool should return nothing; got %v", got)
|
||||||
t.Errorf("empty pool should return zero UUID; got %v", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+192
-62
@@ -12,6 +12,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
@@ -83,32 +84,15 @@ func userIDHash(userID pgtype.UUID, dateStr string) uint64 {
|
|||||||
return binary.BigEndian.Uint64(sum[:8])
|
return binary.BigEndian.Uint64(sum[:8])
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickForYouSeedForDay picks one of the user's top-played candidate
|
// pickDailySeeds takes a candidate pool of UUIDs and returns up to n
|
||||||
// tracks as today's For-You seed. With 5 candidates and SHA-256-based
|
// of them, daily-deterministically shuffled. Each day gets a different
|
||||||
// rotation, each candidate gets picked roughly 1 day in 5; the chosen
|
// ordering / selection, but within-day stability is preserved (same
|
||||||
// seed drives the similarity candidate pool, so the resulting mix is
|
// inputs always produce the same output).
|
||||||
// substantially different across days for a user with diverse top-N
|
|
||||||
// plays.
|
|
||||||
//
|
|
||||||
// Degrades gracefully: 1 candidate → returns it; 0 → returns zero UUID
|
|
||||||
// (caller treats as "no seed available" and skips For-You).
|
|
||||||
func pickForYouSeedForDay(seeds []pgtype.UUID, userID pgtype.UUID, dateStr string) pgtype.UUID {
|
|
||||||
if len(seeds) == 0 {
|
|
||||||
return pgtype.UUID{}
|
|
||||||
}
|
|
||||||
idx := int(userIDHash(userID, dateStr) % uint64(len(seeds)))
|
|
||||||
return seeds[idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickSeedArtistsForDay takes a candidate pool of up to N artists and
|
|
||||||
// returns up to 3 of them, daily-deterministically shuffled. Each day
|
|
||||||
// gets a different ordering / selection, but within-day stability is
|
|
||||||
// preserved (same inputs always produce the same output).
|
|
||||||
//
|
//
|
||||||
// Uses Fisher-Yates over a copy of the input, seeded by userIDHash.
|
// Uses Fisher-Yates over a copy of the input, seeded by userIDHash.
|
||||||
// Degrades gracefully: <3 candidates returns whatever is available in
|
// Degrades gracefully: <n candidates returns whatever is available in
|
||||||
// shuffled order.
|
// shuffled order.
|
||||||
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
|
func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n int) []pgtype.UUID {
|
||||||
if len(pool) == 0 {
|
if len(pool) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -118,13 +102,18 @@ func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr strin
|
|||||||
rng.Shuffle(len(shuffled), func(i, j int) {
|
rng.Shuffle(len(shuffled), func(i, j int) {
|
||||||
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
|
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
|
||||||
})
|
})
|
||||||
n := 3
|
|
||||||
if len(shuffled) < n {
|
if len(shuffled) < n {
|
||||||
n = len(shuffled)
|
n = len(shuffled)
|
||||||
}
|
}
|
||||||
return shuffled[:n]
|
return shuffled[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pickSeedArtistsForDay picks up to 3 seed artists for the Songs-like
|
||||||
|
// mixes; For-You uses pickDailySeeds directly with forYouSeedCount.
|
||||||
|
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
|
||||||
|
return pickDailySeeds(pool, userID, dateStr, 3)
|
||||||
|
}
|
||||||
|
|
||||||
// rankedCandidate is a (track_id, score) pair used during in-memory
|
// rankedCandidate is a (track_id, score) pair used during in-memory
|
||||||
// sorting before insert into playlist_tracks. T5 fills these from
|
// sorting before insert into playlist_tracks. T5 fills these from
|
||||||
// recommendation.Candidate scores. PickKind is the track's provenance
|
// recommendation.Candidate scores. PickKind is the track's provenance
|
||||||
@@ -226,6 +215,122 @@ const (
|
|||||||
forYouTailN = 50
|
forYouTailN = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// forYouSeedCount is how many of the user's top-5 tracks seed each
|
||||||
|
// day's build (#1269). A single rotating seed made the mix bipolar
|
||||||
|
// day-to-day — swinging whole neighborhoods as the rotation moved
|
||||||
|
// between dissimilar anchors; blending 3 spans neighborhoods within
|
||||||
|
// one day and smooths the swing across days.
|
||||||
|
const forYouSeedCount = 3
|
||||||
|
|
||||||
|
// headQuotas fixes each seed's share of the For-You head so one
|
||||||
|
// neighborhood can't monopolize it even when its candidates out-score
|
||||||
|
// the others (#1269): 50/30/20 for three seeds, 60/40 for two, all of
|
||||||
|
// it for one. Rounding remainder goes to the primary seed; a thin
|
||||||
|
// seed's unfilled quota spills best-first in pickQuotaHead.
|
||||||
|
func headQuotas(numSeeds, headN int) []int {
|
||||||
|
if numSeeds <= 1 {
|
||||||
|
return []int{headN}
|
||||||
|
}
|
||||||
|
fractions := []float64{0.5, 0.3, 0.2}
|
||||||
|
if numSeeds == 2 {
|
||||||
|
fractions = []float64{0.6, 0.4}
|
||||||
|
}
|
||||||
|
if numSeeds > len(fractions) {
|
||||||
|
// Not reachable at forYouSeedCount = 3; even split keeps any
|
||||||
|
// future seed-count change from silently starving seeds.
|
||||||
|
fractions = make([]float64, numSeeds)
|
||||||
|
for i := range fractions {
|
||||||
|
fractions[i] = 1.0 / float64(numSeeds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quotas := make([]int, numSeeds)
|
||||||
|
assigned := 0
|
||||||
|
for i := 0; i < numSeeds; i++ {
|
||||||
|
quotas[i] = int(float64(headN) * fractions[i])
|
||||||
|
assigned += quotas[i]
|
||||||
|
}
|
||||||
|
quotas[0] += headN - assigned
|
||||||
|
return quotas
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickQuotaHead walks the score-sorted capped pool best-first, taking
|
||||||
|
// candidates whose originating seed still has head quota; slots a thin
|
||||||
|
// seed can't fill spill best-first regardless of seed in a second
|
||||||
|
// pass. A nil seedOf (single-seed path, tests) attributes everything
|
||||||
|
// to seed 0, which reduces to plain top-headN.
|
||||||
|
func pickQuotaHead(capped []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds, headN int) []recommendation.Candidate {
|
||||||
|
quotas := headQuotas(numSeeds, headN)
|
||||||
|
taken := make([]int, len(quotas))
|
||||||
|
inHead := make(map[pgtype.UUID]bool, headN)
|
||||||
|
head := make([]recommendation.Candidate, 0, headN)
|
||||||
|
for _, c := range capped {
|
||||||
|
if len(head) >= headN {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
si := seedOf[c.Track.ID]
|
||||||
|
if si < 0 || si >= len(quotas) {
|
||||||
|
si = 0
|
||||||
|
}
|
||||||
|
if taken[si] >= quotas[si] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
taken[si]++
|
||||||
|
head = append(head, c)
|
||||||
|
inHead[c.Track.ID] = true
|
||||||
|
}
|
||||||
|
for _, c := range capped {
|
||||||
|
if len(head) >= headN {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if inHead[c.Track.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
head = append(head, c)
|
||||||
|
inHead[c.Track.ID] = true
|
||||||
|
}
|
||||||
|
return head
|
||||||
|
}
|
||||||
|
|
||||||
|
// forYouTailHalfLifeRanks tunes the fresh tail's rank bias (#1269): a
|
||||||
|
// candidate's sampling weight halves every 50 ranks, so the 101st-best
|
||||||
|
// candidate is far likelier than the 401st-best instead of equal —
|
||||||
|
// freshness keeps its "you'll probably enjoy this" half.
|
||||||
|
const forYouTailHalfLifeRanks = 50.0
|
||||||
|
|
||||||
|
// tailSampleUniverse quantizes tieBreakHash into a uniform (0,1] draw
|
||||||
|
// for the weighted sample; 1e6 buckets is plenty of resolution for
|
||||||
|
// pools of a few hundred.
|
||||||
|
const tailSampleUniverse uint64 = 1_000_000
|
||||||
|
|
||||||
|
// pickWeightedTail samples up to tailN candidates from the rank-
|
||||||
|
// ordered tail pool with exponentially rank-decaying weights, using
|
||||||
|
// deterministic Efraimidis-Spirakis keys (u^(1/w), u derived from
|
||||||
|
// tieBreakHash): same (track, day) → same key, so the sample is stable
|
||||||
|
// within a day and rotates across days. Replaces the uniform daily
|
||||||
|
// draw where rank 380 had the same chance as rank 101.
|
||||||
|
func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN int) []recommendation.Candidate {
|
||||||
|
if len(tailPool) <= tailN {
|
||||||
|
return tailPool
|
||||||
|
}
|
||||||
|
type keyed struct {
|
||||||
|
c recommendation.Candidate
|
||||||
|
key float64
|
||||||
|
}
|
||||||
|
keys := make([]keyed, len(tailPool))
|
||||||
|
for i, c := range tailPool {
|
||||||
|
u := (float64(tieBreakHash(c.Track.ID, dateStr)%tailSampleUniverse) + 1) /
|
||||||
|
float64(tailSampleUniverse+1)
|
||||||
|
w := math.Exp2(-float64(i) / forYouTailHalfLifeRanks)
|
||||||
|
keys[i] = keyed{c: c, key: math.Pow(u, 1/w)}
|
||||||
|
}
|
||||||
|
sort.SliceStable(keys, func(i, j int) bool { return keys[i].key > keys[j].key })
|
||||||
|
out := make([]recommendation.Candidate, tailN)
|
||||||
|
for i := 0; i < tailN; i++ {
|
||||||
|
out[i] = keys[i].c
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// scoreAndSortCandidates scores every candidate with recommendation.Score
|
// scoreAndSortCandidates scores every candidate with recommendation.Score
|
||||||
// and returns a new slice sorted by score DESC (ties broken by
|
// and returns a new slice sorted by score DESC (ties broken by
|
||||||
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
|
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
|
||||||
@@ -387,10 +492,11 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// produceForYou: today's seed from the user's top-5 played tracks
|
// produceForYou: blend candidate pools from up to forYouSeedCount of
|
||||||
// (rotates daily via userIDHash), similarity candidate pool, head+
|
// the user's top-5 played tracks (rotating daily via pickDailySeeds),
|
||||||
// tail composition. The base seed query failing is fatal; a
|
// then head+tail composition with per-seed head quotas (#1269). The
|
||||||
// candidate-load failure is logged and yields no For-You.
|
// base seed query failing is fatal; a per-seed candidate-load failure
|
||||||
|
// is logged and that seed just contributes nothing.
|
||||||
func produceForYou(
|
func produceForYou(
|
||||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||||
userID pgtype.UUID, dateStr string, now time.Time,
|
userID pgtype.UUID, dateStr string, now time.Time,
|
||||||
@@ -399,24 +505,40 @@ func produceForYou(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("pick for-you seed candidates: %w", err)
|
return nil, fmt.Errorf("pick for-you seed candidates: %w", err)
|
||||||
}
|
}
|
||||||
forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr)
|
seeds := pickDailySeeds(forYouSeeds, userID, dateStr, forYouSeedCount)
|
||||||
if !forYouSeed.Valid {
|
if len(seeds) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
zeroVec := recommendation.SessionVector{Seed: true}
|
zeroVec := recommendation.SessionVector{Seed: true}
|
||||||
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
// Merge per-seed pools; first-seen wins on dedup, and seedOf
|
||||||
ctx, q, userID, forYouSeed,
|
// remembers which seed sourced each track for the head quotas.
|
||||||
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
|
var merged []recommendation.Candidate
|
||||||
zeroVec,
|
seedOf := map[pgtype.UUID]int{}
|
||||||
[]pgtype.UUID{forYouSeed},
|
for i, seed := range seeds {
|
||||||
systemForYouSourceLimits(),
|
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
||||||
)
|
ctx, q, userID, seed,
|
||||||
if cerr != nil {
|
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
|
||||||
logger.Warn("system playlist: for-you candidates load failed; skipping",
|
zeroVec,
|
||||||
"user_id", uuidStringPL(userID), "err", cerr)
|
seeds,
|
||||||
|
systemForYouSourceLimits(),
|
||||||
|
)
|
||||||
|
if cerr != nil {
|
||||||
|
logger.Warn("system playlist: for-you candidates load failed for seed; continuing",
|
||||||
|
"user_id", uuidStringPL(userID), "seed", uuidStringPL(seed), "err", cerr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range cands {
|
||||||
|
if _, seen := seedOf[c.Track.ID]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seedOf[c.Track.ID] = i
|
||||||
|
merged = append(merged, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(merged) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
tracks := pickHeadAndTail(cands, userID, dateStr, now, forYouHeadN, forYouTailN)
|
tracks := pickHeadAndTail(merged, seedOf, len(seeds), userID, dateStr, now, forYouHeadN, forYouTailN)
|
||||||
if len(tracks) == 0 {
|
if len(tracks) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -672,10 +794,11 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickHeadAndTail picks headN from the score-sorted head plus tailN from
|
// pickHeadAndTail picks headN taste anchors from the score-sorted pool
|
||||||
// positions 2*headN onward (the tail), with the tail sampled
|
// (under per-seed quotas when seedOf/numSeeds describe a multi-seed
|
||||||
// daily-deterministically via tieBreakHash. Caps applied before the
|
// blend, #1269) plus tailN freshness picks sampled rank-weighted from
|
||||||
// head/tail split. Used by For-You only.
|
// positions 2*headN onward. Caps applied before the head/tail split.
|
||||||
|
// Used by For-You only.
|
||||||
//
|
//
|
||||||
// The "tail" — candidates ranked beyond 2*headN — is still similarity-
|
// The "tail" — candidates ranked beyond 2*headN — is still similarity-
|
||||||
// related (every candidate passed the similarity filter) but isn't among
|
// related (every candidate passed the similarity filter) but isn't among
|
||||||
@@ -685,7 +808,10 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
|
|||||||
// Falls back to standard pickTopN behavior when the candidate pool is too
|
// Falls back to standard pickTopN behavior when the candidate pool is too
|
||||||
// small to support a meaningful head/tail split (capped pool <=
|
// small to support a meaningful head/tail split (capped pool <=
|
||||||
// headN+tailN, or no candidates at or beyond position 2*headN).
|
// headN+tailN, or no candidates at or beyond position 2*headN).
|
||||||
func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int) []rankedCandidate {
|
func pickHeadAndTail(
|
||||||
|
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
|
||||||
|
userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
|
||||||
|
) []rankedCandidate {
|
||||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
||||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||||
@@ -709,30 +835,34 @@ func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateS
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
head := capped[:headN]
|
head := pickQuotaHead(capped, seedOf, numSeeds, headN)
|
||||||
|
inHead := make(map[pgtype.UUID]bool, len(head))
|
||||||
|
for _, c := range head {
|
||||||
|
inHead[c.Track.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
tailStart := 2 * headN
|
tailStart := 2 * headN
|
||||||
if tailStart >= len(capped) {
|
if tailStart >= len(capped) {
|
||||||
tailStart = headN
|
tailStart = headN
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defensive copy so that sorting the tail pool does not mutate capped.
|
// The tail pool keeps its rank order (position drives the sampling
|
||||||
tailPool := append([]recommendation.Candidate{}, capped[tailStart:]...)
|
// weight); head members are excluded — a quota walk can reach past
|
||||||
|
// tailStart when a seed's candidates rank deep.
|
||||||
// Sort tail pool by tieBreakHash (daily-deterministic), take tailN.
|
tailPool := make([]recommendation.Candidate, 0, len(capped)-tailStart)
|
||||||
// Sample is stable across requests within a day but varies across days.
|
for _, c := range capped[tailStart:] {
|
||||||
sort.SliceStable(tailPool, func(i, j int) bool {
|
if inHead[c.Track.ID] {
|
||||||
return tieBreakHash(tailPool[i].Track.ID, dateStr) < tieBreakHash(tailPool[j].Track.ID, dateStr)
|
continue
|
||||||
})
|
}
|
||||||
tail := tailPool
|
tailPool = append(tailPool, c)
|
||||||
if len(tail) > tailN {
|
|
||||||
tail = tail[:tailN]
|
|
||||||
}
|
}
|
||||||
|
tail := pickWeightedTail(tailPool, dateStr, tailN)
|
||||||
|
|
||||||
// Combine: head order preserved (score-sorted), tail in tieBreakHash
|
// Combine: head first (score-sorted under quotas), then the fresh
|
||||||
// order. "First similar, then surprise" reads naturally in playback.
|
// sample. "First similar, then surprise" reads naturally in
|
||||||
// Head entries are the taste picks; tail entries are the freshness
|
// playback. Head entries are the taste picks; tail entries are the
|
||||||
// injection (#1249) — the split the metrics page attributes skips to.
|
// freshness injection (#1249) — the split the metrics page
|
||||||
|
// attributes skips to.
|
||||||
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
|
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
|
||||||
combined = append(combined, head...)
|
combined = append(combined, head...)
|
||||||
combined = append(combined, tail...)
|
combined = append(combined, tail...)
|
||||||
|
|||||||
@@ -83,10 +83,14 @@ func buildYouMightLike(
|
|||||||
"user_id", uuidStringPL(userID), "err", err)
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
return youMightLikeResult{built: false}
|
return youMightLikeResult{built: false}
|
||||||
}
|
}
|
||||||
seed := pickForYouSeedForDay(seeds, userID, dateStr)
|
// One rotating seed is right here (unlike For-You's multi-seed
|
||||||
if !seed.Valid {
|
// blend, #1269): the row is a short shelf, not a mix, and a single
|
||||||
|
// neighborhood per day keeps it coherent.
|
||||||
|
daily := pickDailySeeds(seeds, userID, dateStr, 1)
|
||||||
|
if len(daily) == 0 {
|
||||||
return youMightLikeResult{built: true}
|
return youMightLikeResult{built: true}
|
||||||
}
|
}
|
||||||
|
seed := daily[0]
|
||||||
|
|
||||||
zeroVec := recommendation.SessionVector{Seed: true}
|
zeroVec := recommendation.SessionVector{Seed: true}
|
||||||
// You-might-like surfaces in-library artists the user does NOT actively
|
// You-might-like surfaces in-library artists the user does NOT actively
|
||||||
|
|||||||
Reference in New Issue
Block a user