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
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>
246 lines
7.6 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|