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,
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 -4
View File
@@ -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
+117 -42
View File
@@ -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
+36 -2
View File
@@ -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
+65 -18
View File
@@ -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) {