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