feat: genre mixes pick specific genres that don't share songs (#6933)
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>
This commit is contained in:
2026-11-07 14:55:19 -05:00
co-authored by Claude Opus 5.5
parent 14debe2ea9
commit dabe1e052d
5 changed files with 262 additions and 80 deletions
+30 -14
View File
@@ -17,7 +17,7 @@ WITH library_genres AS (
SELECT lower(trim(g.g)) AS genre_key, SELECT lower(trim(g.g)) AS genre_key,
min(trim(g.g))::text AS label, min(trim(g.g))::text AS label,
COUNT(DISTINCT t.id) AS track_count, COUNT(DISTINCT t.id) AS track_count,
array_agg(DISTINCT t.artist_id)::uuid[] AS artist_ids array_agg(DISTINCT t.id)::uuid[] AS track_ids
FROM tracks t FROM tracks t
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
WHERE t.missing_since IS NULL WHERE t.missing_since IS NULL
@@ -59,7 +59,7 @@ SELECT lg.genre_key::text AS genre_key,
(CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight (CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight
WHEN h.genre_key IS NOT NULL THEN h.plays::float8 WHEN h.genre_key IS NOT NULL THEN h.plays::float8
ELSE lg.track_count::float8 END)::float8 AS score, ELSE lg.track_count::float8 END)::float8 AS score,
lg.artist_ids lg.track_ids
FROM library_genres lg FROM library_genres lg
LEFT JOIN taste ta ON ta.genre_key = lg.genre_key LEFT JOIN taste ta ON ta.genre_key = lg.genre_key
LEFT JOIN history h ON h.genre_key = lg.genre_key LEFT JOIN history h ON h.genre_key = lg.genre_key
@@ -68,11 +68,11 @@ SELECT lg.genre_key::text AS genre_key,
` `
type ListGenreMixCandidatesRow struct { type ListGenreMixCandidatesRow struct {
GenreKey string GenreKey string
Label string Label string
Tier int32 Tier int32
Score float64 Score float64
ArtistIds []pgtype.UUID TrackIds []pgtype.UUID
} }
// Candidate queries for the genre mixes: one daily system playlist per genre // Candidate queries for the genre mixes: one daily system playlist per genre
@@ -89,8 +89,10 @@ type ListGenreMixCandidatesRow struct {
// has decayed past its window // has decayed past its window
// tier 3: any other library genre, scored by its size // tier 3: any other library genre, scored by its size
// //
// artist_ids lets the caller skip a genre whose artists mostly repeat one // track_ids lets the caller tell how much music two genres share: a genre
// already picked (Indie Rock after Alternative Rock). // whose songs mostly sit in genres already picked adds no new channel, and a
// broad genre ("Rock") whose songs mostly carry narrower ones ("Pop Punk",
// "Ska Punk") gives way to them.
// $1 user_id. // $1 user_id.
func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID) ([]ListGenreMixCandidatesRow, error) { func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID) ([]ListGenreMixCandidatesRow, error) {
rows, err := q.db.Query(ctx, listGenreMixCandidates, userID) rows, err := q.db.Query(ctx, listGenreMixCandidates, userID)
@@ -106,7 +108,7 @@ func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID
&i.Label, &i.Label,
&i.Tier, &i.Tier,
&i.Score, &i.Score,
&i.ArtistIds, &i.TrackIds,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -189,6 +191,11 @@ eligible AS (
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
WHERE lower(trim(g.g)) = $3::text WHERE lower(trim(g.g)) = $3::text
) )
AND NOT EXISTS (
SELECT 1
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
WHERE lower(trim(g.g)) = ANY($4::text[])
)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
@@ -207,9 +214,10 @@ SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
` `
type ListGenreMixTracksParams struct { type ListGenreMixTracksParams struct {
UserID pgtype.UUID UserID pgtype.UUID
DateSeed string DateSeed string
GenreKey string GenreKey string
ExcludeGenres []string
} }
type ListGenreMixTracksRow struct { type ListGenreMixTracksRow struct {
@@ -244,8 +252,16 @@ type ListGenreMixTracksRow struct {
// A track the user keeps skipping stays out: twice-skipped and skipped more // A track the user keeps skipping stays out: twice-skipped and skipped more
// often than played through. The second half of that test keeps a favourite // often than played through. The second half of that test keeps a favourite
// with a couple of stray skips in. // with a couple of stray skips in.
//
// exclude_genres keeps each song in one mix: a track carrying a genre that
// already has a mix today is left to that mix.
func (q *Queries) ListGenreMixTracks(ctx context.Context, arg ListGenreMixTracksParams) ([]ListGenreMixTracksRow, error) { func (q *Queries) ListGenreMixTracks(ctx context.Context, arg ListGenreMixTracksParams) ([]ListGenreMixTracksRow, error) {
rows, err := q.db.Query(ctx, listGenreMixTracks, arg.UserID, arg.DateSeed, arg.GenreKey) rows, err := q.db.Query(ctx, listGenreMixTracks,
arg.UserID,
arg.DateSeed,
arg.GenreKey,
arg.ExcludeGenres,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+14 -4
View File
@@ -14,14 +14,16 @@
-- has decayed past its window -- has decayed past its window
-- tier 3: any other library genre, scored by its size -- tier 3: any other library genre, scored by its size
-- --
-- artist_ids lets the caller skip a genre whose artists mostly repeat one -- track_ids lets the caller tell how much music two genres share: a genre
-- already picked (Indie Rock after Alternative Rock). -- whose songs mostly sit in genres already picked adds no new channel, and a
-- broad genre ("Rock") whose songs mostly carry narrower ones ("Pop Punk",
-- "Ska Punk") gives way to them.
-- $1 user_id. -- $1 user_id.
WITH library_genres AS ( WITH library_genres AS (
SELECT lower(trim(g.g)) AS genre_key, SELECT lower(trim(g.g)) AS genre_key,
min(trim(g.g))::text AS label, min(trim(g.g))::text AS label,
COUNT(DISTINCT t.id) AS track_count, COUNT(DISTINCT t.id) AS track_count,
array_agg(DISTINCT t.artist_id)::uuid[] AS artist_ids array_agg(DISTINCT t.id)::uuid[] AS track_ids
FROM tracks t FROM tracks t
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
WHERE t.missing_since IS NULL WHERE t.missing_since IS NULL
@@ -63,7 +65,7 @@ SELECT lg.genre_key::text AS genre_key,
(CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight (CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight
WHEN h.genre_key IS NOT NULL THEN h.plays::float8 WHEN h.genre_key IS NOT NULL THEN h.plays::float8
ELSE lg.track_count::float8 END)::float8 AS score, ELSE lg.track_count::float8 END)::float8 AS score,
lg.artist_ids lg.track_ids
FROM library_genres lg FROM library_genres lg
LEFT JOIN taste ta ON ta.genre_key = lg.genre_key LEFT JOIN taste ta ON ta.genre_key = lg.genre_key
LEFT JOIN history h ON h.genre_key = lg.genre_key LEFT JOIN history h ON h.genre_key = lg.genre_key
@@ -96,6 +98,9 @@ SELECT lg.genre_key::text AS genre_key,
-- A track the user keeps skipping stays out: twice-skipped and skipped more -- A track the user keeps skipping stays out: twice-skipped and skipped more
-- often than played through. The second half of that test keeps a favourite -- often than played through. The second half of that test keeps a favourite
-- with a couple of stray skips in. -- with a couple of stray skips in.
--
-- exclude_genres keeps each song in one mix: a track carrying a genre that
-- already has a mix today is left to that mix.
WITH play_counts AS ( WITH play_counts AS (
SELECT pe.track_id, COUNT(*) AS c SELECT pe.track_id, COUNT(*) AS c
FROM play_events pe FROM play_events pe
@@ -166,6 +171,11 @@ eligible AS (
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
WHERE lower(trim(g.g)) = sqlc.arg(genre_key)::text WHERE lower(trim(g.g)) = sqlc.arg(genre_key)::text
) )
AND NOT EXISTS (
SELECT 1
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
WHERE lower(trim(g.g)) = ANY(sqlc.arg(exclude_genres)::text[])
)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
+117 -42
View File
@@ -21,6 +21,14 @@ import (
// listened genres gets those, topped up to genreMixMinShelf, rather than // listened genres gets those, topped up to genreMixMinShelf, rather than
// ten channels of music they never play. // ten channels of music they never play.
// //
// The shelf is a set of channels that sound different from one another. Tags
// overlap heavily (a lo-fi beat is often tagged Lo-Fi, Hip Hop, Chillwave and
// Instrumental at once), so the pick is made on songs, not names: a genre
// whose songs mostly sit in a genre already picked adds no new channel, and a
// broad genre whose songs mostly carry narrower ones gives way to them, so
// the shelf holds Pop Punk and Ska Punk rather than one Rock mix. Each song
// belongs to at most one mix.
//
// The set of genres is stable from day to day, like a list of channels; the // The set of genres is stable from day to day, like a list of channels; the
// tracks inside each mix change daily. ListGenreMixTracks draws them by a // tracks inside each mix change daily. ListGenreMixTracks draws them by a
// date-seeded weighted lottery: familiar tracks lean toward the ones played // date-seeded weighted lottery: familiar tracks lean toward the ones played
@@ -43,22 +51,26 @@ const (
// Either side running short is filled from the other. // Either side running short is filled from the other.
genreMixFamiliarParts = 3 genreMixFamiliarParts = 3
genreMixParts = 5 genreMixParts = 5
// genreMixMaxOverlap skips a candidate genre whose artist set is a near // genreMixMinNewShare is the share of a genre's songs that must not be
// duplicate of one already picked: shared artists / all artists of the // in a genre already picked for it to get a mix of its own.
// pair (Jaccard) at or above this. Symmetric on purpose — a broad genreMixMinNewShare = 0.5
// "Rock" does not knock out every rock subgenre, but "Lo-Fi" and // A genre is broad when at least genreMixBroadShare of its songs also
// "Lo-Fi Hip Hop" with the same artists collapse to one mix. // carry a clearly narrower candidate genre: one at most
genreMixMaxOverlap = 0.5 // genreMixNarrowerSize times its size with at least half of its songs
// inside it. Broad genres are considered after the specific ones of
// their tier.
genreMixBroadShare = 0.5
genreMixNarrowerSize = 0.5
// genreMixTier3 is the tier of genres the user has no history with. // genreMixTier3 is the tier of genres the user has no history with.
genreMixTier3 = 3 genreMixTier3 = 3
) )
// genreCandidate is one row of ListGenreMixCandidates. // genreCandidate is one row of ListGenreMixCandidates.
type genreCandidate struct { type genreCandidate struct {
Key string Key string
Label string Label string
Tier int32 Tier int32
ArtistIDs []pgtype.UUID TrackIDs []pgtype.UUID
} }
// genreTrack is one row of ListGenreMixTracks. // genreTrack is one row of ListGenreMixTracks.
@@ -80,13 +92,14 @@ func produceGenreMixes(
} }
cands := make([]genreCandidate, len(rows)) cands := make([]genreCandidate, len(rows))
for i, r := range rows { for i, r := range rows {
cands[i] = genreCandidate{Key: r.GenreKey, Label: r.Label, Tier: r.Tier, ArtistIDs: r.ArtistIds} cands[i] = genreCandidate{Key: r.GenreKey, Label: r.Label, Tier: r.Tier, TrackIDs: r.TrackIds}
} }
build := func(c genreCandidate) []rankedCandidate { build := func(c genreCandidate, exclude []string) []rankedCandidate {
trackRows, terr := q.ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{ trackRows, terr := q.ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
UserID: userID, UserID: userID,
GenreKey: c.Key, GenreKey: c.Key,
DateSeed: dateStr, DateSeed: dateStr,
ExcludeGenres: exclude,
}) })
if terr != nil { if terr != nil {
logger.Warn("system playlist: genre mix tracks query failed; skipping genre", logger.Warn("system playlist: genre mix tracks query failed; skipping genre",
@@ -105,28 +118,43 @@ func produceGenreMixes(
return selectGenreMixes(cands, build), nil return selectGenreMixes(cands, build), nil
} }
// selectGenreMixes walks the candidates in rank order and keeps the ones // selectGenreMixes walks the candidates, specific genres before broad ones
// that make a real mix, skipping a genre that mostly repeats the artists of // within each tier, and keeps the ones that make a real mix of music the
// one already kept, and taking tier-3 genres only while the shelf is below // shelf does not have yet. It takes tier-3 genres only while the shelf is
// genreMixMinShelf. // below genreMixMinShelf. build is handed the genres kept so far, whose songs
func selectGenreMixes(cands []genreCandidate, build func(genreCandidate) []rankedCandidate) []builtPlaylist { // the new mix leaves out.
var kept []genreCandidate func selectGenreMixes(
cands []genreCandidate, build func(genreCandidate, []string) []rankedCandidate,
) []builtPlaylist {
sets := make([]map[pgtype.UUID]struct{}, len(cands))
for i, c := range cands {
sets[i] = make(map[pgtype.UUID]struct{}, len(c.TrackIDs))
for _, id := range c.TrackIDs {
sets[i][id] = struct{}{}
}
}
owned := map[pgtype.UUID]struct{}{}
kept := []string{}
var out []builtPlaylist var out []builtPlaylist
for _, c := range cands { for _, i := range specificFirst(cands, sets) {
c := cands[i]
if len(out) >= genreMixCount { if len(out) >= genreMixCount {
break break
} }
if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf { if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf {
break break
} }
if overlapsKept(c, kept) { if !mostlyNew(sets[i], owned) {
continue continue
} }
tracks := build(c) tracks := build(c, kept)
if len(tracks) < genreMixMinLen { if len(tracks) < genreMixMinLen {
continue continue
} }
kept = append(kept, c) kept = append(kept, c.Key)
for id := range sets[i] {
owned[id] = struct{}{}
}
out = append(out, builtPlaylist{ out = append(out, builtPlaylist{
Name: c.Label + " mix", Name: c.Label + " mix",
Variant: "genre_mix", Variant: "genre_mix",
@@ -136,29 +164,76 @@ func selectGenreMixes(cands []genreCandidate, build func(genreCandidate) []ranke
return out return out
} }
// overlapsKept reports whether c's artists are a near duplicate (Jaccard at // specificFirst returns the candidates' indexes in rank order, except that
// or above genreMixMaxOverlap) of any single kept genre's. // within each tier the broad genres come after the specific ones.
func overlapsKept(c genreCandidate, kept []genreCandidate) bool { func specificFirst(cands []genreCandidate, sets []map[pgtype.UUID]struct{}) []int {
if len(c.ArtistIDs) == 0 { broad := make([]bool, len(cands))
return false for i := range cands {
broad[i] = isBroad(i, sets)
} }
for _, k := range kept { order := make([]int, 0, len(cands))
in := make(map[pgtype.UUID]struct{}, len(k.ArtistIDs)) for start := 0; start < len(cands); {
for _, a := range k.ArtistIDs { end := start
in[a] = struct{}{} for end < len(cands) && cands[end].Tier == cands[start].Tier {
end++
} }
shared := 0 for _, wantBroad := range []bool{false, true} {
for _, a := range c.ArtistIDs { for i := start; i < end; i++ {
if _, ok := in[a]; ok { if broad[i] == wantBroad {
shared++ order = append(order, i)
}
} }
} }
union := len(in) + len(c.ArtistIDs) - shared start = end
if float64(shared) >= genreMixMaxOverlap*float64(union) { }
return true return order
}
// isBroad reports whether at least genreMixBroadShare of genre g's songs
// also carry a clearly narrower candidate genre.
func isBroad(g int, sets []map[pgtype.UUID]struct{}) bool {
if len(sets[g]) == 0 {
return false
}
var narrower []map[pgtype.UUID]struct{}
for h, set := range sets {
if h == g || float64(len(set)) > genreMixNarrowerSize*float64(len(sets[g])) {
continue
}
if 2*sharedCount(set, sets[g]) >= len(set) {
narrower = append(narrower, set)
} }
} }
return false covered := 0
for id := range sets[g] {
for _, set := range narrower {
if _, ok := set[id]; ok {
covered++
break
}
}
}
return float64(covered) >= genreMixBroadShare*float64(len(sets[g]))
}
// mostlyNew reports whether at least genreMixMinNewShare of set's songs are
// not yet owned by a kept genre.
func mostlyNew(set, owned map[pgtype.UUID]struct{}) bool {
return float64(len(set)-sharedCount(set, owned)) >= genreMixMinNewShare*float64(len(set))
}
// sharedCount counts the songs in both a and b.
func sharedCount(a, b map[pgtype.UUID]struct{}) int {
if len(b) < len(a) {
a, b = b, a
}
n := 0
for id := range a {
if _, ok := b[id]; ok {
n++
}
}
return n
} }
// composeGenreMix interleaves familiar and unheard tracks 3 to 5 // composeGenreMix interleaves familiar and unheard tracks 3 to 5
+36 -2
View File
@@ -64,8 +64,8 @@ func TestListGenreMixCandidates_TiersAndSizeFloor(t *testing.T) {
if _, mine := want[r.Label]; mine { if _, mine := want[r.Label]; mine {
order = append(order, r.Label) order = append(order, r.Label)
} }
if r.Label == "Shoegaze" && len(r.ArtistIds) != 6 { if r.Label == "Shoegaze" && len(r.TrackIds) != 30 {
t.Errorf("Shoegaze artist_ids = %d, want 6", len(r.ArtistIds)) t.Errorf("Shoegaze track_ids = %d, want 30", len(r.TrackIds))
} }
} }
for label, w := range want { for label, w := range want {
@@ -81,6 +81,40 @@ func TestListGenreMixCandidates_TiersAndSizeFloor(t *testing.T) {
} }
} }
// TestListGenreMixTracks_LeavesOutSongsOfGenresAlreadyPicked: a track that
// also carries an excluded genre (matched case-insensitively) belongs to that
// genre's mix and is left out of this one.
func TestListGenreMixTracks_LeavesOutSongsOfGenresAlreadyPicked(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "gmixexcl")
punk := seedGenre(t, pool, "Ska Punk", 4, 5)
shared := punk[:5]
for _, tr := range shared {
setGenre(t, pool, tr.ID, "Ska Punk; Pop Punk")
}
rows, err := dbq.New(pool).ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
UserID: u.ID, DateSeed: "2026-11-07", GenreKey: "ska punk", ExcludeGenres: []string{"pop punk"},
})
if err != nil {
t.Fatalf("list tracks: %v", err)
}
if len(rows) != len(punk)-len(shared) {
t.Fatalf("rows = %d, want %d (the Ska Punk tracks not also Pop Punk)", len(rows), len(punk)-len(shared))
}
got := map[pgtype.UUID]bool{}
for _, r := range rows {
got[r.ID] = true
}
for _, tr := range shared {
if got[tr.ID] {
t.Fatal("a track also tagged Pop Punk was offered after Pop Punk was excluded")
}
}
}
// TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks: played and // TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks: played and
// liked tracks come back familiar, the rest unheard; a track skipped more // liked tracks come back familiar, the rest unheard; a track skipped more
// often than played through is left out, while a favourite with stray skips // often than played through is left out, while a favourite with stray skips
+65 -18
View File
@@ -92,39 +92,85 @@ func TestComposeGenreMix_CapsOneArtistThenTopsUpANarrowGenre(t *testing.T) {
} }
} }
func artists(ns ...int) []pgtype.UUID { // songs returns the track ids from..to inclusive.
out := make([]pgtype.UUID, len(ns)) func songs(from, to int) []pgtype.UUID {
for i, n := range ns { out := make([]pgtype.UUID, 0, to-from+1)
out[i] = uuidN(n) for n := from; n <= to; n++ {
out = append(out, uuidN(n))
} }
return out return out
} }
func TestOverlapsKept_NearDuplicatesCollapseButSubgenresOfABroadGenreStay(t *testing.T) { func genreOf(label string, tier int32, ids ...[]pgtype.UUID) genreCandidate {
rock := genreCandidate{Label: "Rock", ArtistIDs: artists(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)} var all []pgtype.UUID
altRock := genreCandidate{Label: "Alternative Rock", ArtistIDs: artists(1, 2, 3)} for _, part := range ids {
lofi := genreCandidate{Label: "Lo-Fi", ArtistIDs: artists(20, 21, 22, 23)} all = append(all, part...)
lofiHipHop := genreCandidate{Label: "Lo-Fi Hip Hop", ArtistIDs: artists(20, 21, 22, 24)} }
return genreCandidate{Key: label, Label: label, Tier: tier, TrackIDs: all}
}
if overlapsKept(altRock, []genreCandidate{rock}) { // A genre whose songs mostly sit in one already picked is the same channel
t.Error("Alternative Rock (3 of Rock's 10 artists) collapsed into Rock; a subgenre of a broad genre should stay") // 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)),
} }
if !overlapsKept(lofiHipHop, []genreCandidate{rock, lofi}) { got := labels(selectGenreMixes(cands, buildN(30)))
t.Error("Lo-Fi Hip Hop (3 of 5 artists shared with Lo-Fi) was kept; near duplicates should collapse") if len(got) != 2 || got[0] != "Lo-Fi mix" || got[1] != "Jazz mix" {
t.Fatalf("mixes = %v, want [Lo-Fi mix Jazz mix]", got)
} }
if overlapsKept(lofi, nil) { }
t.Error("nothing kept yet, but overlap reported")
// 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 // buildN returns a builder that gives every genre n tracks, except those
// listed in short, which get too few to make a mix. // listed in short, which get too few to make a mix.
func buildN(n int, short ...string) func(genreCandidate) []rankedCandidate { func buildN(n int, short ...string) func(genreCandidate, []string) []rankedCandidate {
thin := map[string]bool{} thin := map[string]bool{}
for _, s := range short { for _, s := range short {
thin[s] = true thin[s] = true
} }
return func(c genreCandidate) []rankedCandidate { return func(c genreCandidate, _ []string) []rankedCandidate {
if thin[c.Label] { if thin[c.Label] {
return make([]rankedCandidate, genreMixMinLen-1) return make([]rankedCandidate, genreMixMinLen-1)
} }
@@ -140,8 +186,9 @@ func labels(built []builtPlaylist) []string {
return out 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 { func distinctGenre(label string, tier int32, n int) genreCandidate {
return genreCandidate{Key: label, Label: label, Tier: tier, ArtistIDs: artists(n)} return genreOf(label, tier, songs(n*100, n*100+99))
} }
func TestSelectGenreMixes_TakesTheUsersGenresAndStopsAtTheCount(t *testing.T) { func TestSelectGenreMixes_TakesTheUsersGenresAndStopsAtTheCount(t *testing.T) {