feat: genre mixes — a daily playlist per genre you listen to, on a "Your genres" Home row (#6904)
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 25s
release / integration (push) Failing after 4m38s
release / android (push) Successful in 4m3s
release / Build signed APK (push) Successful in 4m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 25s
release / integration (push) Failing after 4m38s
release / android (push) Successful in 4m3s
release / Build signed APK (push) Successful in 4m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A new system playlist kind, genre_mix: up to 10 "{Genre} mix" playlists per
user, rebuilt daily with the other system mixes.
Which genres: library genres with at least 25 playable tracks across 3
artists, ranked in tiers — taste-profile genres first (matched
case-insensitively, so ID3 "Post-Punk" and folksonomy "post-punk" are one),
then genres the user has played or liked, then the rest of the library, which
only tops a short shelf up to 4. A genre whose artist set is a near
duplicate of one already picked (Jaccard >= 0.5) is skipped, so "Lo-Fi" and
"Lo-Fi Hip Hop" don't both appear but "Alternative Rock" survives "Rock". A
genre that can't fill 15 tracks gives way to the next.
Each mix is 50 tracks: 3 in 5 the user already knows (played through or
liked), the rest unheard in that genre, interleaved, with the usual
per-album/per-artist caps. Tracks skipped twice and more often than played
are left out. The order changes daily and holds within a day. Picks are
stamped taste / taste_unheard, and genre_mix plays count toward rotation and
the recommendation metrics.
Migration 0102 adds genre_mix to the playlists variant constraints.
Web and Android each get a "Your genres" row in name order, hidden until a
mix exists. Kinds with several playlists per user now play by playlist id
wherever the client used to test seed_artist_id (web card, detail page
self-heal), keyed on the server's refreshable flag; on Android genre_mix
joins songs_like_artist as not refreshable. The web detail page's
"mix was rebuilt" reload now finds a Songs-like or genre mix by name instead
of calling the by-kind shuffle, which those kinds don't have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: genre_mixes.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const listGenreMixCandidates = `-- name: ListGenreMixCandidates :many
|
||||
|
||||
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
|
||||
FROM tracks t
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE t.missing_since IS NULL
|
||||
AND trim(g.g) <> ''
|
||||
GROUP BY lower(trim(g.g))
|
||||
HAVING COUNT(DISTINCT t.id) >= 25
|
||||
AND COUNT(DISTINCT t.artist_id) >= 3
|
||||
),
|
||||
taste AS (
|
||||
SELECT lower(trim(tpt.tag)) AS genre_key, SUM(tpt.weight) AS weight
|
||||
FROM taste_profile_tags tpt
|
||||
WHERE tpt.user_id = $1
|
||||
GROUP BY lower(trim(tpt.tag))
|
||||
HAVING SUM(tpt.weight) > 0
|
||||
),
|
||||
listened AS (
|
||||
SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
UNION ALL
|
||||
SELECT gl.track_id
|
||||
FROM general_likes gl
|
||||
WHERE gl.user_id = $1
|
||||
),
|
||||
history AS (
|
||||
SELECT lower(trim(g.g)) AS genre_key, COUNT(*) AS plays
|
||||
FROM listened l
|
||||
JOIN tracks t ON t.id = l.id
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE trim(g.g) <> ''
|
||||
GROUP BY lower(trim(g.g))
|
||||
)
|
||||
SELECT lg.genre_key::text AS genre_key,
|
||||
lg.label AS label,
|
||||
(CASE WHEN ta.genre_key IS NOT NULL THEN 1
|
||||
WHEN h.genre_key IS NOT NULL THEN 2
|
||||
ELSE 3 END)::int AS tier,
|
||||
(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
|
||||
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
|
||||
ORDER BY tier, score DESC, lg.genre_key
|
||||
LIMIT 60
|
||||
`
|
||||
|
||||
type ListGenreMixCandidatesRow struct {
|
||||
GenreKey string
|
||||
Label string
|
||||
Tier int32
|
||||
Score float64
|
||||
ArtistIds []pgtype.UUID
|
||||
}
|
||||
|
||||
// Candidate queries for the genre mixes: one daily system playlist per genre
|
||||
// the user listens to. Genres live inline on tracks.genre as a delimited
|
||||
// string and are split on [;,] the same way the Discover and radio tag arms
|
||||
// split them. Matching is case-insensitive, so the ID3 "Post-Punk" and the
|
||||
// folksonomy "post-punk" in the taste profile count as one genre.
|
||||
// Library genres big enough to carry a mix (>= 25 playable tracks across
|
||||
// >= 3 artists), each tagged with how close it is to the user's listening:
|
||||
//
|
||||
// tier 1: a positive taste-profile genre, scored by its taste weight
|
||||
// tier 2: a genre of tracks the user has played through or liked, scored
|
||||
// by how many — catches a profile that has not been built yet or
|
||||
// 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).
|
||||
// $1 user_id.
|
||||
func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID) ([]ListGenreMixCandidatesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listGenreMixCandidates, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListGenreMixCandidatesRow
|
||||
for rows.Next() {
|
||||
var i ListGenreMixCandidatesRow
|
||||
if err := rows.Scan(
|
||||
&i.GenreKey,
|
||||
&i.Label,
|
||||
&i.Tier,
|
||||
&i.Score,
|
||||
&i.ArtistIds,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listGenreMixTracks = `-- name: ListGenreMixTracks :many
|
||||
WITH play_counts AS (
|
||||
SELECT pe.track_id, COUNT(*) AS c
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
skip_counts AS (
|
||||
SELECT pe.track_id, COUNT(*) AS c
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = true
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
eligible AS (
|
||||
SELECT t.id, t.album_id, t.artist_id,
|
||||
(COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar,
|
||||
md5(t.id::text || $2::text) AS tiebreak
|
||||
FROM tracks t
|
||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||
LEFT JOIN general_likes gl ON gl.user_id = $1 AND gl.track_id = t.id
|
||||
WHERE t.missing_since IS NULL -- never offer a file that is gone
|
||||
AND NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0))
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE lower(trim(g.g)) = $3::text
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT e.id, e.album_id, e.artist_id, e.familiar, e.tiebreak, row_number() OVER (PARTITION BY e.familiar ORDER BY e.tiebreak) AS rn
|
||||
FROM eligible e
|
||||
)
|
||||
SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
|
||||
FROM ranked r
|
||||
WHERE r.rn <= 200
|
||||
ORDER BY r.familiar DESC, r.tiebreak
|
||||
`
|
||||
|
||||
type ListGenreMixTracksParams struct {
|
||||
UserID pgtype.UUID
|
||||
DateSeed string
|
||||
GenreKey string
|
||||
}
|
||||
|
||||
type ListGenreMixTracksRow struct {
|
||||
ID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Familiar bool
|
||||
}
|
||||
|
||||
// Playable tracks carrying one genre, split into the two halves a genre mix
|
||||
// blends: familiar (the user has played it through or liked it) and unheard.
|
||||
// Up to 200 of each, each half in a daily-deterministic order so the mix
|
||||
// changes day to day and holds still within a day.
|
||||
//
|
||||
// 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.
|
||||
func (q *Queries) ListGenreMixTracks(ctx context.Context, arg ListGenreMixTracksParams) ([]ListGenreMixTracksRow, error) {
|
||||
rows, err := q.db.Query(ctx, listGenreMixTracks, arg.UserID, arg.DateSeed, arg.GenreKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListGenreMixTracksRow
|
||||
for rows.Next() {
|
||||
var i ListGenreMixTracksRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AlbumID,
|
||||
&i.ArtistID,
|
||||
&i.Familiar,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Restores the 0028 constraint set. Genre-mix rows would violate it, so they
|
||||
-- go first.
|
||||
DELETE FROM playlists WHERE system_variant = 'genre_mix';
|
||||
|
||||
ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_kind_variant_consistent;
|
||||
ALTER TABLE playlists
|
||||
ADD CONSTRAINT playlists_kind_variant_consistent CHECK (
|
||||
(kind = 'user' AND system_variant IS NULL)
|
||||
OR
|
||||
(kind = 'system' AND system_variant IN (
|
||||
'for_you', 'songs_like_artist', 'discover',
|
||||
'deep_cuts', 'rediscover', 'new_for_you',
|
||||
'on_this_day', 'first_listens'
|
||||
))
|
||||
);
|
||||
|
||||
ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_seed_consistent;
|
||||
ALTER TABLE playlists
|
||||
ADD CONSTRAINT playlists_seed_consistent CHECK (
|
||||
(system_variant IS NULL AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'for_you' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'discover' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'deep_cuts' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'rediscover' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'new_for_you' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'on_this_day' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'first_listens' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'songs_like_artist' AND seed_artist_id IS NOT NULL)
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Genre mixes: one daily system playlist per genre the user listens to
|
||||
-- ("Post-Punk mix", "Jazz mix", ...). Several per user, like
|
||||
-- songs_like_artist, but seedless: the genre lives in the playlist name and
|
||||
-- seed_artist_id is NULL.
|
||||
--
|
||||
-- Postgres has no ALTER CONSTRAINT for CHECK, so both constraints are
|
||||
-- dropped and re-added with 'genre_mix' in the accepted set.
|
||||
|
||||
ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_kind_variant_consistent;
|
||||
ALTER TABLE playlists
|
||||
ADD CONSTRAINT playlists_kind_variant_consistent CHECK (
|
||||
(kind = 'user' AND system_variant IS NULL)
|
||||
OR
|
||||
(kind = 'system' AND system_variant IN (
|
||||
'for_you', 'songs_like_artist', 'discover',
|
||||
'deep_cuts', 'rediscover', 'new_for_you',
|
||||
'on_this_day', 'first_listens', 'genre_mix'
|
||||
))
|
||||
);
|
||||
|
||||
ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_seed_consistent;
|
||||
ALTER TABLE playlists
|
||||
ADD CONSTRAINT playlists_seed_consistent CHECK (
|
||||
(system_variant IS NULL AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'for_you' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'discover' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'deep_cuts' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'rediscover' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'new_for_you' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'on_this_day' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'first_listens' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'genre_mix' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'songs_like_artist' AND seed_artist_id IS NOT NULL)
|
||||
);
|
||||
@@ -0,0 +1,121 @@
|
||||
-- Candidate queries for the genre mixes: one daily system playlist per genre
|
||||
-- the user listens to. Genres live inline on tracks.genre as a delimited
|
||||
-- string and are split on [;,] the same way the Discover and radio tag arms
|
||||
-- split them. Matching is case-insensitive, so the ID3 "Post-Punk" and the
|
||||
-- folksonomy "post-punk" in the taste profile count as one genre.
|
||||
|
||||
-- name: ListGenreMixCandidates :many
|
||||
-- Library genres big enough to carry a mix (>= 25 playable tracks across
|
||||
-- >= 3 artists), each tagged with how close it is to the user's listening:
|
||||
--
|
||||
-- tier 1: a positive taste-profile genre, scored by its taste weight
|
||||
-- tier 2: a genre of tracks the user has played through or liked, scored
|
||||
-- by how many — catches a profile that has not been built yet or
|
||||
-- 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).
|
||||
-- $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
|
||||
FROM tracks t
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE t.missing_since IS NULL
|
||||
AND trim(g.g) <> ''
|
||||
GROUP BY lower(trim(g.g))
|
||||
HAVING COUNT(DISTINCT t.id) >= 25
|
||||
AND COUNT(DISTINCT t.artist_id) >= 3
|
||||
),
|
||||
taste AS (
|
||||
SELECT lower(trim(tpt.tag)) AS genre_key, SUM(tpt.weight) AS weight
|
||||
FROM taste_profile_tags tpt
|
||||
WHERE tpt.user_id = $1
|
||||
GROUP BY lower(trim(tpt.tag))
|
||||
HAVING SUM(tpt.weight) > 0
|
||||
),
|
||||
listened AS (
|
||||
SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
UNION ALL
|
||||
SELECT gl.track_id
|
||||
FROM general_likes gl
|
||||
WHERE gl.user_id = $1
|
||||
),
|
||||
history AS (
|
||||
SELECT lower(trim(g.g)) AS genre_key, COUNT(*) AS plays
|
||||
FROM listened l
|
||||
JOIN tracks t ON t.id = l.id
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE trim(g.g) <> ''
|
||||
GROUP BY lower(trim(g.g))
|
||||
)
|
||||
SELECT lg.genre_key::text AS genre_key,
|
||||
lg.label AS label,
|
||||
(CASE WHEN ta.genre_key IS NOT NULL THEN 1
|
||||
WHEN h.genre_key IS NOT NULL THEN 2
|
||||
ELSE 3 END)::int AS tier,
|
||||
(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
|
||||
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
|
||||
ORDER BY tier, score DESC, lg.genre_key
|
||||
LIMIT 60;
|
||||
|
||||
-- name: ListGenreMixTracks :many
|
||||
-- Playable tracks carrying one genre, split into the two halves a genre mix
|
||||
-- blends: familiar (the user has played it through or liked it) and unheard.
|
||||
-- Up to 200 of each, each half in a daily-deterministic order so the mix
|
||||
-- changes day to day and holds still within a day.
|
||||
--
|
||||
-- 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.
|
||||
WITH play_counts AS (
|
||||
SELECT pe.track_id, COUNT(*) AS c
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = sqlc.arg(user_id) AND pe.was_skipped = false
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
skip_counts AS (
|
||||
SELECT pe.track_id, COUNT(*) AS c
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = sqlc.arg(user_id) AND pe.was_skipped = true
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
eligible AS (
|
||||
SELECT t.id, t.album_id, t.artist_id,
|
||||
(COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar,
|
||||
md5(t.id::text || sqlc.arg(date_seed)::text) AS tiebreak
|
||||
FROM tracks t
|
||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||
LEFT JOIN general_likes gl ON gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id
|
||||
WHERE t.missing_since IS NULL -- never offer a file that is gone
|
||||
AND NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0))
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
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 quarantine q
|
||||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||
)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT e.*, row_number() OVER (PARTITION BY e.familiar ORDER BY e.tiebreak) AS rn
|
||||
FROM eligible e
|
||||
)
|
||||
SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
|
||||
FROM ranked r
|
||||
WHERE r.rn <= 200
|
||||
ORDER BY r.familiar DESC, r.tiebreak;
|
||||
Reference in New Issue
Block a user