Files
minstrel/internal/playlists/genre_mixes_test.go
T
bvandeusenandClaude Opus 5.5 dbaa8b7d57
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 25s
release / integration (push) Failing after 4m38s
release / android (push) Successful in 4m3s
release / Build signed APK (push) Successful in 4m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: genre mixes — a daily playlist per genre you listen to, on a "Your genres" Home row (#6904)
A new system playlist kind, genre_mix: up to 10 "{Genre} mix" playlists per
user, rebuilt daily with the other system mixes.

Which genres: library genres with at least 25 playable tracks across 3
artists, ranked in tiers — taste-profile genres first (matched
case-insensitively, so ID3 "Post-Punk" and folksonomy "post-punk" are one),
then genres the user has played or liked, then the rest of the library, which
only tops a short shelf up to 4. A genre whose artist set is a near
duplicate of one already picked (Jaccard >= 0.5) is skipped, so "Lo-Fi" and
"Lo-Fi Hip Hop" don't both appear but "Alternative Rock" survives "Rock". A
genre that can't fill 15 tracks gives way to the next.

Each mix is 50 tracks: 3 in 5 the user already knows (played through or
liked), the rest unheard in that genre, interleaved, with the usual
per-album/per-artist caps. Tracks skipped twice and more often than played
are left out. The order changes daily and holds within a day. Picks are
stamped taste / taste_unheard, and genre_mix plays count toward rotation and
the recommendation metrics.

Migration 0102 adds genre_mix to the playlists variant constraints.

Web and Android each get a "Your genres" row in name order, hidden until a
mix exists. Kinds with several playlists per user now play by playlist id
wherever the client used to test seed_artist_id (web card, detail page
self-heal), keyed on the server's refreshable flag; on Android genre_mix
joins songs_like_artist as not refreshable. The web detail page's
"mix was rebuilt" reload now finds a Songs-like or genre mix by name instead
of calling the by-kind shuffle, which those kinds don't have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:30:41 -05:00

199 lines
6.0 KiB
Go

package playlists
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
// genreTracks builds n tracks, each on its own album and artist (so the
// caps never bind), numbered from start.
func genreTracks(start, n int, familiar bool) []genreTrack {
out := make([]genreTrack, n)
for i := range out {
id := uuidN(start + i)
out[i] = genreTrack{
discoverTrack: discoverTrack{ID: id, AlbumID: id, ArtistID: id},
Familiar: familiar,
}
}
return out
}
func kindsOf(tracks []rankedCandidate) string {
s := make([]byte, len(tracks))
for i, t := range tracks {
switch t.PickKind {
case pickKindTaste:
s[i] = 'F'
case pickKindTasteUnheard:
s[i] = 'U'
default:
s[i] = '?'
}
}
return string(s)
}
func TestComposeGenreMix_InterleavesThreeFamiliarToTwoUnheard(t *testing.T) {
tracks := append(genreTracks(1, 20, true), genreTracks(100, 20, false)...)
got := composeGenreMix(tracks, 10)
if want := "FFUFUFFUFU"; kindsOf(got) != want {
t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want)
}
}
func TestComposeGenreMix_ShortSideIsFilledFromTheOther(t *testing.T) {
// Two familiar tracks only: the rest of the mix is unheard, not short.
tracks := append(genreTracks(1, 2, true), genreTracks(100, 20, false)...)
got := composeGenreMix(tracks, 10)
if len(got) != 10 {
t.Fatalf("len = %d, want 10", len(got))
}
if want := "FFUUUUUUUU"; kindsOf(got) != want {
t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want)
}
// No unheard tracks at all: an all-familiar mix.
got = composeGenreMix(genreTracks(1, 12, true), 10)
if want := "FFFFFFFFFF"; kindsOf(got) != want {
t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want)
}
}
func TestComposeGenreMix_CapsOneArtistThenTopsUpANarrowGenre(t *testing.T) {
// One artist, one album, 8 tracks, plus 4 single-track artists. The cap
// keeps 2 of the big album; the top-up refills from it so the mix still
// reaches 8.
big := genreTracks(1, 8, false)
for i := range big {
big[i].AlbumID = uuidN(500)
big[i].ArtistID = uuidN(500)
}
tracks := append(big, genreTracks(100, 4, false)...)
got := composeGenreMix(tracks, 8)
if len(got) != 8 {
t.Fatalf("len = %d, want 8", len(got))
}
// The first six are the capped pool: 2 from the big album, then the 4
// singles — the big album does not lead the mix wholesale.
bigID := map[pgtype.UUID]bool{}
for _, b := range big {
bigID[b.ID] = true
}
inHead := 0
for _, c := range got[:6] {
if bigID[c.TrackID] {
inHead++
}
}
if inHead != 2 {
t.Fatalf("big-album tracks in the capped head = %d, want 2", inHead)
}
}
func artists(ns ...int) []pgtype.UUID {
out := make([]pgtype.UUID, len(ns))
for i, n := range ns {
out[i] = uuidN(n)
}
return out
}
func TestOverlapsKept_NearDuplicatesCollapseButSubgenresOfABroadGenreStay(t *testing.T) {
rock := genreCandidate{Label: "Rock", ArtistIDs: artists(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)}
altRock := genreCandidate{Label: "Alternative Rock", ArtistIDs: artists(1, 2, 3)}
lofi := genreCandidate{Label: "Lo-Fi", ArtistIDs: artists(20, 21, 22, 23)}
lofiHipHop := genreCandidate{Label: "Lo-Fi Hip Hop", ArtistIDs: artists(20, 21, 22, 24)}
if overlapsKept(altRock, []genreCandidate{rock}) {
t.Error("Alternative Rock (3 of Rock's 10 artists) collapsed into Rock; a subgenre of a broad genre should stay")
}
if !overlapsKept(lofiHipHop, []genreCandidate{rock, lofi}) {
t.Error("Lo-Fi Hip Hop (3 of 5 artists shared with Lo-Fi) was kept; near duplicates should collapse")
}
if overlapsKept(lofi, nil) {
t.Error("nothing kept yet, but overlap reported")
}
}
// buildN returns a builder that gives every genre n tracks, except those
// listed in short, which get too few to make a mix.
func buildN(n int, short ...string) func(genreCandidate) []rankedCandidate {
thin := map[string]bool{}
for _, s := range short {
thin[s] = true
}
return func(c genreCandidate) []rankedCandidate {
if thin[c.Label] {
return make([]rankedCandidate, genreMixMinLen-1)
}
return make([]rankedCandidate, n)
}
}
func labels(built []builtPlaylist) []string {
out := make([]string, len(built))
for i, b := range built {
out[i] = b.Name
}
return out
}
func distinctGenre(label string, tier int32, n int) genreCandidate {
return genreCandidate{Key: label, Label: label, Tier: tier, ArtistIDs: artists(n)}
}
func TestSelectGenreMixes_TakesTheUsersGenresAndStopsAtTheCount(t *testing.T) {
var cands []genreCandidate
for i := 0; i < 14; i++ {
cands = append(cands, distinctGenre(string(rune('A'+i)), 1, i+1))
}
got := selectGenreMixes(cands, buildN(30))
if len(got) != genreMixCount {
t.Fatalf("built %d mixes, want %d", len(got), genreMixCount)
}
if got[0].Name != "A mix" || got[0].Variant != "genre_mix" {
t.Fatalf("first mix = %q (%s), want \"A mix\" (genre_mix)", got[0].Name, got[0].Variant)
}
}
func TestSelectGenreMixes_ThinGenreGivesWayToTheNext(t *testing.T) {
cands := []genreCandidate{
distinctGenre("Jazz", 1, 1),
distinctGenre("Dub", 1, 2),
distinctGenre("Soul", 2, 3),
}
got := labels(selectGenreMixes(cands, buildN(30, "Dub")))
if len(got) != 2 || got[0] != "Jazz mix" || got[1] != "Soul mix" {
t.Fatalf("mixes = %v, want [Jazz mix Soul mix]", got)
}
}
func TestSelectGenreMixes_LibraryGenresOnlyFillAShortShelf(t *testing.T) {
// Two listened genres: tier 3 tops the shelf up to genreMixMinShelf.
cands := []genreCandidate{
distinctGenre("Jazz", 1, 1),
distinctGenre("Soul", 2, 2),
distinctGenre("Polka", 3, 3),
distinctGenre("Opera", 3, 4),
distinctGenre("Ska", 3, 5),
}
got := labels(selectGenreMixes(cands, buildN(30)))
if len(got) != genreMixMinShelf {
t.Fatalf("mixes = %v, want %d", got, genreMixMinShelf)
}
// Enough listened genres: no tier-3 genre at all.
var full []genreCandidate
for i := 0; i < genreMixMinShelf; i++ {
full = append(full, distinctGenre(string(rune('A'+i)), 1, i+1))
}
full = append(full, distinctGenre("Polka", 3, 99))
for _, name := range labels(selectGenreMixes(full, buildN(30))) {
if name == "Polka mix" {
t.Fatal("a library-only genre was added to a shelf the user's own genres already fill")
}
}
}