diff --git a/internal/db/dbq/genre_mixes.sql.go b/internal/db/dbq/genre_mixes.sql.go index 8163d723..23b120c5 100644 --- a/internal/db/dbq/genre_mixes.sql.go +++ b/internal/db/dbq/genre_mixes.sql.go @@ -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 } diff --git a/internal/db/queries/genre_mixes.sql b/internal/db/queries/genre_mixes.sql index 330ed094..6859a8dc 100644 --- a/internal/db/queries/genre_mixes.sql +++ b/internal/db/queries/genre_mixes.sql @@ -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 diff --git a/internal/playlists/genre_mixes.go b/internal/playlists/genre_mixes.go index cde3969c..2bbc41ca 100644 --- a/internal/playlists/genre_mixes.go +++ b/internal/playlists/genre_mixes.go @@ -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 diff --git a/internal/playlists/genre_mixes_db_test.go b/internal/playlists/genre_mixes_db_test.go index efc9d628..3ccdfc24 100644 --- a/internal/playlists/genre_mixes_db_test.go +++ b/internal/playlists/genre_mixes_db_test.go @@ -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 diff --git a/internal/playlists/genre_mixes_test.go b/internal/playlists/genre_mixes_test.go index 023d4ffc..b09f683a 100644 --- a/internal/playlists/genre_mixes_test.go +++ b/internal/playlists/genre_mixes_test.go @@ -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) {