feat: genre mixes lean toward what the listener likes (#6921)
release / web (push) Successful in 1m40s
release / go (push) Successful in 2m5s
release / govulncheck (push) Successful in 19s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 4m40s
release / Build signed APK (push) Successful in 5m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped

Within a genre both halves of the mix were a uniform daily draw: a song
played forty times was no likelier than one played once, and the unheard
40% ignored who the artist was. ListGenreMixTracks now draws each half by a
date-seeded weighted lottery (Efraimidis-Spirakis: smallest -ln(u)/weight
wins), so the mix still changes daily and holds within a day.

- Familiar: 1 + ln(1 + plays) + 1 if liked, times the share of plays that
  weren't skips.
- Unheard: 1 + ln(1 + the artist's plays + 2 x its liked tracks)
  + 2 x the artist's best similarity to the user's own artists, times the
  artist's non-skip share. An unknown artist weighs 1, so new artists still
  come up.

Which genres are offered, the exclusions and the familiar/unheard blend
are unchanged. DB tests check the lean averaged over ten days, and that a
stranger's tracks stay in the pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 14:21:04 -05:00
co-authored by Claude Opus 5.5
parent adbb3a3cd8
commit 14debe2ea9
4 changed files with 240 additions and 11 deletions
+4 -1
View File
@@ -22,7 +22,10 @@ import (
// ten channels of music they never play.
//
// The set of genres is stable from day to day, like a list of channels; the
// tracks inside each mix change daily.
// tracks inside each mix change daily. ListGenreMixTracks draws them by a
// date-seeded weighted lottery: familiar tracks lean toward the ones played
// most and liked, unheard ones toward the user's artists and artists similar
// to them, and every track keeps some chance.
const (
// genreMixCount is the most genre mixes a user gets.
+102
View File
@@ -200,3 +200,105 @@ func TestBuildSystemPlaylists_GenreMixes(t *testing.T) {
t.Errorf("pick kinds = %v, want both taste and taste_unheard", kinds)
}
}
// genreDrawPositions runs ListGenreMixTracks for ten different days and
// returns, per track, the sum of its positions within its own half
// (familiar or unheard). The draw is random per day, so a lean shows up as
// an earlier average position across days, not in any single one.
func genreDrawPositions(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, genreKey string) map[pgtype.UUID]int {
t.Helper()
sum := map[pgtype.UUID]int{}
for day := 1; day <= 10; day++ {
rows, err := dbq.New(pool).ListGenreMixTracks(context.Background(), dbq.ListGenreMixTracksParams{
UserID: userID, DateSeed: fmt.Sprintf("2026-11-%02d", day), GenreKey: genreKey,
})
if err != nil {
t.Fatalf("list tracks: %v", err)
}
pos := map[bool]int{}
for _, r := range rows {
sum[r.ID] += pos[r.Familiar]
pos[r.Familiar]++
}
}
return sum
}
func meanPosition(sum map[pgtype.UUID]int, tracks []dbq.Track) float64 {
total := 0
for _, tr := range tracks {
total += sum[tr.ID]
}
return float64(total) / float64(len(tracks))
}
// TestListGenreMixTracks_FamiliarLeansTowardFavourites: among the tracks the
// user knows, ones played often and liked come up earlier than ones played
// once.
func TestListGenreMixTracks_FamiliarLeansTowardFavourites(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "gmixfav")
now := time.Now().UTC()
tracks := seedGenre(t, pool, "Krautrock", 4, 5)
favourites, onceOnly := tracks[:10], tracks[10:]
for i, tr := range favourites {
for k := 0; k < 5; k++ {
seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(i*10+k+1)*time.Hour), false)
}
if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, tr.ID); err != nil {
t.Fatalf("like: %v", err)
}
}
for i, tr := range onceOnly {
seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(200+i)*time.Hour), false)
}
sum := genreDrawPositions(t, pool, u.ID, "krautrock")
fav, once := meanPosition(sum, favourites), meanPosition(sum, onceOnly)
if fav >= once {
t.Errorf("favourites' summed position %.1f, played-once %.1f: want favourites earlier", fav, once)
}
}
// TestListGenreMixTracks_UnheardLeansTowardTheUsersArtists: among unheard
// tracks, an artist the user plays and likes comes up earlier than one they
// have never met, and so does an artist similar to theirs. The stranger
// still appears: the lean tilts the draw, it does not exclude.
func TestListGenreMixTracks_UnheardLeansTowardTheUsersArtists(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "gmixartist")
now := time.Now().UTC()
tracks := seedGenre(t, pool, "Shoegaze", 3, 12)
loved, similar, stranger := tracks[:12], tracks[12:24], tracks[24:]
for k := 0; k < 15; k++ {
seedPlayEvent(t, pool, u.ID, loved[0].ID, now.Add(-time.Duration(k+1)*time.Hour), false)
}
for _, tr := range loved[1:3] {
if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, tr.ID); err != nil {
t.Fatalf("like: %v", err)
}
}
if _, err := pool.Exec(ctx, `
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source)
VALUES ($1, $2, 0.9, 'listenbrainz')`, loved[0].ArtistID, similar[0].ArtistID); err != nil {
t.Fatalf("seed similarity: %v", err)
}
sum := genreDrawPositions(t, pool, u.ID, "shoegaze")
l, s, x := meanPosition(sum, loved[3:]), meanPosition(sum, similar), meanPosition(sum, stranger)
if l >= x {
t.Errorf("loved artist's unheard tracks at %.1f, stranger's at %.1f: want the loved artist earlier", l, x)
}
if s >= x {
t.Errorf("similar artist's tracks at %.1f, stranger's at %.1f: want the similar artist earlier", s, x)
}
for _, tr := range stranger {
if _, in := sum[tr.ID]; !in {
t.Fatalf("a stranger's track was left out of the pool; the lean must not exclude")
}
}
}