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
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:
@@ -17,7 +17,7 @@ WITH library_genres AS (
|
||||
SELECT lower(trim(g.g)) AS genre_key,
|
||||
min(trim(g.g))::text AS label,
|
||||
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
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
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
|
||||
WHEN h.genre_key IS NOT NULL THEN h.plays::float8
|
||||
ELSE lg.track_count::float8 END)::float8 AS score,
|
||||
lg.artist_ids
|
||||
lg.track_ids
|
||||
FROM library_genres lg
|
||||
LEFT JOIN taste ta ON ta.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 {
|
||||
GenreKey string
|
||||
Label string
|
||||
Tier int32
|
||||
Score float64
|
||||
ArtistIds []pgtype.UUID
|
||||
GenreKey string
|
||||
Label string
|
||||
Tier int32
|
||||
Score float64
|
||||
TrackIds []pgtype.UUID
|
||||
}
|
||||
|
||||
// Candidate queries for the genre mixes: one daily system playlist per genre
|
||||
@@ -89,8 +89,10 @@ type ListGenreMixCandidatesRow struct {
|
||||
// has decayed past its window
|
||||
// tier 3: any other library genre, scored by its size
|
||||
//
|
||||
// artist_ids lets the caller skip a genre whose artists mostly repeat one
|
||||
// already picked (Indie Rock after Alternative Rock).
|
||||
// track_ids lets the caller tell how much music two genres share: a genre
|
||||
// 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.
|
||||
func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID) ([]ListGenreMixCandidatesRow, error) {
|
||||
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.Tier,
|
||||
&i.Score,
|
||||
&i.ArtistIds,
|
||||
&i.TrackIds,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -189,6 +191,11 @@ eligible AS (
|
||||
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
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 (
|
||||
SELECT 1 FROM quarantine q
|
||||
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 {
|
||||
UserID pgtype.UUID
|
||||
DateSeed string
|
||||
GenreKey string
|
||||
UserID pgtype.UUID
|
||||
DateSeed string
|
||||
GenreKey string
|
||||
ExcludeGenres []string
|
||||
}
|
||||
|
||||
type ListGenreMixTracksRow struct {
|
||||
@@ -244,8 +252,16 @@ type ListGenreMixTracksRow struct {
|
||||
// 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
|
||||
// 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) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,14 +14,16 @@
|
||||
-- has decayed past its window
|
||||
-- tier 3: any other library genre, scored by its size
|
||||
--
|
||||
-- artist_ids lets the caller skip a genre whose artists mostly repeat one
|
||||
-- already picked (Indie Rock after Alternative Rock).
|
||||
-- track_ids lets the caller tell how much music two genres share: a genre
|
||||
-- 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.
|
||||
WITH library_genres AS (
|
||||
SELECT lower(trim(g.g)) AS genre_key,
|
||||
min(trim(g.g))::text AS label,
|
||||
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
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
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
|
||||
WHEN h.genre_key IS NOT NULL THEN h.plays::float8
|
||||
ELSE lg.track_count::float8 END)::float8 AS score,
|
||||
lg.artist_ids
|
||||
lg.track_ids
|
||||
FROM library_genres lg
|
||||
LEFT JOIN taste ta ON ta.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
|
||||
-- often than played through. The second half of that test keeps a favourite
|
||||
-- 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 (
|
||||
SELECT pe.track_id, COUNT(*) AS c
|
||||
FROM play_events pe
|
||||
@@ -166,6 +171,11 @@ eligible AS (
|
||||
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
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 (
|
||||
SELECT 1 FROM quarantine q
|
||||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||
|
||||
@@ -21,6 +21,14 @@ import (
|
||||
// listened genres gets those, topped up to genreMixMinShelf, rather than
|
||||
// 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
|
||||
// tracks inside each mix change daily. ListGenreMixTracks draws them by a
|
||||
// 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.
|
||||
genreMixFamiliarParts = 3
|
||||
genreMixParts = 5
|
||||
// genreMixMaxOverlap skips a candidate genre whose artist set is a near
|
||||
// duplicate of one already picked: shared artists / all artists of the
|
||||
// pair (Jaccard) at or above this. Symmetric on purpose — a broad
|
||||
// "Rock" does not knock out every rock subgenre, but "Lo-Fi" and
|
||||
// "Lo-Fi Hip Hop" with the same artists collapse to one mix.
|
||||
genreMixMaxOverlap = 0.5
|
||||
// genreMixMinNewShare is the share of a genre's songs that must not be
|
||||
// in a genre already picked for it to get a mix of its own.
|
||||
genreMixMinNewShare = 0.5
|
||||
// A genre is broad when at least genreMixBroadShare of its songs also
|
||||
// carry a clearly narrower candidate genre: one at most
|
||||
// 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 = 3
|
||||
)
|
||||
|
||||
// genreCandidate is one row of ListGenreMixCandidates.
|
||||
type genreCandidate struct {
|
||||
Key string
|
||||
Label string
|
||||
Tier int32
|
||||
ArtistIDs []pgtype.UUID
|
||||
Key string
|
||||
Label string
|
||||
Tier int32
|
||||
TrackIDs []pgtype.UUID
|
||||
}
|
||||
|
||||
// genreTrack is one row of ListGenreMixTracks.
|
||||
@@ -80,13 +92,14 @@ func produceGenreMixes(
|
||||
}
|
||||
cands := make([]genreCandidate, len(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{
|
||||
UserID: userID,
|
||||
GenreKey: c.Key,
|
||||
DateSeed: dateStr,
|
||||
UserID: userID,
|
||||
GenreKey: c.Key,
|
||||
DateSeed: dateStr,
|
||||
ExcludeGenres: exclude,
|
||||
})
|
||||
if terr != nil {
|
||||
logger.Warn("system playlist: genre mix tracks query failed; skipping genre",
|
||||
@@ -105,28 +118,43 @@ func produceGenreMixes(
|
||||
return selectGenreMixes(cands, build), nil
|
||||
}
|
||||
|
||||
// selectGenreMixes walks the candidates in rank order and keeps the ones
|
||||
// that make a real mix, skipping a genre that mostly repeats the artists of
|
||||
// one already kept, and taking tier-3 genres only while the shelf is below
|
||||
// genreMixMinShelf.
|
||||
func selectGenreMixes(cands []genreCandidate, build func(genreCandidate) []rankedCandidate) []builtPlaylist {
|
||||
var kept []genreCandidate
|
||||
// selectGenreMixes walks the candidates, specific genres before broad ones
|
||||
// within each tier, and keeps the ones that make a real mix of music the
|
||||
// shelf does not have yet. It takes tier-3 genres only while the shelf is
|
||||
// below genreMixMinShelf. build is handed the genres kept so far, whose songs
|
||||
// the new mix leaves out.
|
||||
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
|
||||
for _, c := range cands {
|
||||
for _, i := range specificFirst(cands, sets) {
|
||||
c := cands[i]
|
||||
if len(out) >= genreMixCount {
|
||||
break
|
||||
}
|
||||
if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf {
|
||||
break
|
||||
}
|
||||
if overlapsKept(c, kept) {
|
||||
if !mostlyNew(sets[i], owned) {
|
||||
continue
|
||||
}
|
||||
tracks := build(c)
|
||||
tracks := build(c, kept)
|
||||
if len(tracks) < genreMixMinLen {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, c)
|
||||
kept = append(kept, c.Key)
|
||||
for id := range sets[i] {
|
||||
owned[id] = struct{}{}
|
||||
}
|
||||
out = append(out, builtPlaylist{
|
||||
Name: c.Label + " mix",
|
||||
Variant: "genre_mix",
|
||||
@@ -136,29 +164,76 @@ func selectGenreMixes(cands []genreCandidate, build func(genreCandidate) []ranke
|
||||
return out
|
||||
}
|
||||
|
||||
// overlapsKept reports whether c's artists are a near duplicate (Jaccard at
|
||||
// or above genreMixMaxOverlap) of any single kept genre's.
|
||||
func overlapsKept(c genreCandidate, kept []genreCandidate) bool {
|
||||
if len(c.ArtistIDs) == 0 {
|
||||
return false
|
||||
// specificFirst returns the candidates' indexes in rank order, except that
|
||||
// within each tier the broad genres come after the specific ones.
|
||||
func specificFirst(cands []genreCandidate, sets []map[pgtype.UUID]struct{}) []int {
|
||||
broad := make([]bool, len(cands))
|
||||
for i := range cands {
|
||||
broad[i] = isBroad(i, sets)
|
||||
}
|
||||
for _, k := range kept {
|
||||
in := make(map[pgtype.UUID]struct{}, len(k.ArtistIDs))
|
||||
for _, a := range k.ArtistIDs {
|
||||
in[a] = struct{}{}
|
||||
order := make([]int, 0, len(cands))
|
||||
for start := 0; start < len(cands); {
|
||||
end := start
|
||||
for end < len(cands) && cands[end].Tier == cands[start].Tier {
|
||||
end++
|
||||
}
|
||||
shared := 0
|
||||
for _, a := range c.ArtistIDs {
|
||||
if _, ok := in[a]; ok {
|
||||
shared++
|
||||
for _, wantBroad := range []bool{false, true} {
|
||||
for i := start; i < end; i++ {
|
||||
if broad[i] == wantBroad {
|
||||
order = append(order, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
union := len(in) + len(c.ArtistIDs) - shared
|
||||
if float64(shared) >= genreMixMaxOverlap*float64(union) {
|
||||
return true
|
||||
start = end
|
||||
}
|
||||
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
|
||||
|
||||
@@ -64,8 +64,8 @@ func TestListGenreMixCandidates_TiersAndSizeFloor(t *testing.T) {
|
||||
if _, mine := want[r.Label]; mine {
|
||||
order = append(order, r.Label)
|
||||
}
|
||||
if r.Label == "Shoegaze" && len(r.ArtistIds) != 6 {
|
||||
t.Errorf("Shoegaze artist_ids = %d, want 6", len(r.ArtistIds))
|
||||
if r.Label == "Shoegaze" && len(r.TrackIds) != 30 {
|
||||
t.Errorf("Shoegaze track_ids = %d, want 30", len(r.TrackIds))
|
||||
}
|
||||
}
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -92,39 +92,85 @@ func TestComposeGenreMix_CapsOneArtistThenTopsUpANarrowGenre(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func artists(ns ...int) []pgtype.UUID {
|
||||
out := make([]pgtype.UUID, len(ns))
|
||||
for i, n := range ns {
|
||||
out[i] = uuidN(n)
|
||||
// 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 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)}
|
||||
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}
|
||||
}
|
||||
|
||||
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")
|
||||
// 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)),
|
||||
}
|
||||
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")
|
||||
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)
|
||||
}
|
||||
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
|
||||
// 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{}
|
||||
for _, s := range short {
|
||||
thin[s] = true
|
||||
}
|
||||
return func(c genreCandidate) []rankedCandidate {
|
||||
return func(c genreCandidate, _ []string) []rankedCandidate {
|
||||
if thin[c.Label] {
|
||||
return make([]rankedCandidate, genreMixMinLen-1)
|
||||
}
|
||||
@@ -140,8 +186,9 @@ func labels(built []builtPlaylist) []string {
|
||||
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 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) {
|
||||
|
||||
Reference in New Issue
Block a user