Files
minstrel/internal/playlists/genre_mixes_test.go
T
bvandeusenandClaude Opus 5.5 dabe1e052d
release / go (push) Successful in 2m0s
release / web (push) Successful in 1m33s
release / govulncheck (push) Successful in 18s
release / integration (push) Successful in 5m11s
release / android (push) Successful in 4m14s
release / Build signed APK (push) Successful in 4m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: genre mixes pick specific genres that don't share songs (#6933)
The shelf had four mixes built largely from the same lo-fi beats: about
1,250 tracks carry Hip Hop, Lo-Fi, Instrumental, Downtempo and four more
tags at once, and the near-duplicate guard compared artists, not songs, so
each tag passed as its own channel.

Genre selection now works on songs:
- ListGenreMixCandidates returns each genre's track ids instead of its
  artist ids.
- A broad genre, one whose songs mostly also carry clearly narrower
  candidates (Rock over Pop Punk and Ska Punk), is considered after the
  specific genres of its tier.
- A genre is kept only when at least half its songs are not already in a
  kept genre.
- Each mix leaves out songs tagged with a genre kept before it
  (ListGenreMixTracks exclude_genres), so a song is in one mix at most.

The tiers, the minimum shelf and the minimum mix length are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:55:19 -05:00

246 lines
7.6 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)
}
}
// songs returns the track ids from..to inclusive.
func songs(from, to int) []pgtype.UUID {
out := make([]pgtype.UUID, 0, to-from+1)
for n := from; n <= to; n++ {
out = append(out, uuidN(n))
}
return out
}
func genreOf(label string, tier int32, ids ...[]pgtype.UUID) genreCandidate {
var all []pgtype.UUID
for _, part := range ids {
all = append(all, part...)
}
return genreCandidate{Key: label, Label: label, Tier: tier, TrackIDs: all}
}
// A genre whose songs mostly sit in one already picked is the same channel
// under another name: Lo-Fi Hip Hop after Lo-Fi.
func TestSelectGenreMixes_AGenreOfMostlyPickedSongsGivesWay(t *testing.T) {
cands := []genreCandidate{
genreOf("Lo-Fi", 1, songs(1, 100)),
genreOf("Lo-Fi Hip Hop", 1, songs(1, 80), songs(500, 510)),
genreOf("Jazz", 1, songs(1000, 1100)),
}
got := labels(selectGenreMixes(cands, buildN(30)))
if len(got) != 2 || got[0] != "Lo-Fi mix" || got[1] != "Jazz mix" {
t.Fatalf("mixes = %v, want [Lo-Fi mix Jazz mix]", got)
}
}
// A broad genre whose songs mostly carry narrower genres gives way to them,
// even when it ranks higher, and each later mix leaves out the songs of the
// genres picked before it.
func TestSelectGenreMixes_ABroadGenreGivesWayToItsSpecificOnes(t *testing.T) {
cands := []genreCandidate{
genreOf("Rock", 1, songs(1, 1000)),
genreOf("Pop Punk", 1, songs(1, 300)),
genreOf("Ska Punk", 1, songs(250, 600)),
}
excluded := map[string][]string{}
build := func(c genreCandidate, exclude []string) []rankedCandidate {
excluded[c.Label] = append([]string(nil), exclude...)
return make([]rankedCandidate, 30)
}
got := labels(selectGenreMixes(cands, build))
if len(got) != 2 || got[0] != "Pop Punk mix" || got[1] != "Ska Punk mix" {
t.Fatalf("mixes = %v, want [Pop Punk mix Ska Punk mix]", got)
}
if ex := excluded["Ska Punk"]; len(ex) != 1 || ex[0] != "Pop Punk" {
t.Fatalf("Ska Punk mix excludes %v, want [Pop Punk]", ex)
}
if ex := excluded["Pop Punk"]; len(ex) != 0 {
t.Fatalf("first mix excludes %v, want nothing", ex)
}
}
// A genre only partly covered by narrower ones is a channel of its own, and
// keeps its rank.
func TestSelectGenreMixes_AGenreMostlyItsOwnKeepsItsRank(t *testing.T) {
cands := []genreCandidate{
genreOf("Rock", 1, songs(1, 2000)),
genreOf("Pop Punk", 1, songs(1, 300)),
genreOf("Jazz", 1, songs(3000, 3100)),
}
got := labels(selectGenreMixes(cands, buildN(30)))
if len(got) != 2 || got[0] != "Rock mix" || got[1] != "Jazz mix" {
t.Fatalf("mixes = %v, want [Rock mix Jazz mix]", got)
}
}
// 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, []string) []rankedCandidate {
thin := map[string]bool{}
for _, s := range short {
thin[s] = true
}
return func(c genreCandidate, _ []string) []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
}
// distinctGenre is a genre of 100 songs of its own, numbered from n*100.
func distinctGenre(label string, tier int32, n int) genreCandidate {
return genreOf(label, tier, songs(n*100, n*100+99))
}
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")
}
}
}