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
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>
199 lines
6.0 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|