Files
minstrel/internal/db/dbq/genre_mixes.sql.go
T
bvandeusenandClaude Opus 5.5 dabe1e052d
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
feat: genre mixes pick specific genres that don't share songs (#6933)
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>
2026-11-07 14:55:19 -05:00

287 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.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
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.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
ORDER BY tier, score DESC, lg.genre_key
LIMIT 60
`
type ListGenreMixCandidatesRow struct {
GenreKey string
Label string
Tier int32
Score float64
TrackIds []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
//
// 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)
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.TrackIds,
); 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
),
artist_plays AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE NOT pe.was_skipped) AS plays,
COUNT(*) FILTER (WHERE pe.was_skipped) AS skips
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
GROUP BY t.artist_id
),
artist_likes AS (
SELECT t.artist_id, COUNT(*) AS likes
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1
GROUP BY t.artist_id
),
own_artists AS (
SELECT ap.artist_id FROM artist_plays ap WHERE ap.plays > 0
UNION
SELECT al.artist_id FROM artist_likes al
),
artist_sim AS (
SELECT asim.artist_b_id AS artist_id,
MAX(LEAST(GREATEST(asim.score, 0), 1)) AS sim
FROM artist_similarity asim
JOIN own_artists oa ON oa.artist_id = asim.artist_a_id
GROUP BY asim.artist_b_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,
CASE
WHEN COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL THEN
(1 + ln(1 + COALESCE(pc.c, 0)::float8)
+ CASE WHEN gl.track_id IS NOT NULL THEN 1 ELSE 0 END)
* ((COALESCE(pc.c, 0) + 1)::float8 / (COALESCE(pc.c, 0) + COALESCE(sc.c, 0) + 1))
ELSE
(1 + ln(1 + COALESCE(ap.plays, 0)::float8 + 2 * COALESCE(al.likes, 0)::float8)
+ 2 * COALESCE(asm.sim, 0))
* ((COALESCE(ap.plays, 0) + 1)::float8 / (COALESCE(ap.plays, 0) + COALESCE(ap.skips, 0) + 1))
END::float8 AS weight,
-- u in (0, 1): the first 32 bits of md5(track, date), shifted off 0.
((('x' || substr(md5(t.id::text || $2::text), 1, 8))::bit(32)::bigint)::float8 + 1)
/ 4294967297.0 AS u
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
LEFT JOIN artist_plays ap ON ap.artist_id = t.artist_id
LEFT JOIN artist_likes al ON al.artist_id = t.artist_id
LEFT JOIN artist_sim asm ON asm.artist_id = t.artist_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 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
)
),
ranked AS (
SELECT e.id, e.album_id, e.artist_id, e.familiar,
-ln(e.u) / e.weight AS draw,
row_number() OVER (PARTITION BY e.familiar ORDER BY -ln(e.u) / e.weight, e.id) 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.draw, r.id
`
type ListGenreMixTracksParams struct {
UserID pgtype.UUID
DateSeed string
GenreKey string
ExcludeGenres []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, drawn by a weighted lottery seeded with the date, so the
// mix changes day to day, holds still within a day, and leans toward what the
// user likes without becoming the same 50 songs every day.
//
// The lottery is the Efraimidis-Spirakis draw: each track gets
// -ln(u) / weight with u uniform in (0, 1) from md5(track, date), and the
// smallest keys win. A track of weight 3 is three times as likely as one of
// weight 1 to come before it, and every track keeps some chance.
//
// Familiar weight: 1 + ln(1 + plays through) + 1 if liked, times the share of
// its plays that were not skips. A song played 40 times weighs about 4.7, one
// played once about 1.7.
//
// Unheard weight: 1 + ln(1 + the artist's plays through + 2 × its liked
// tracks) + 2 × how similar the artist is to the user's own artists (the best
// artist_similarity score from any artist they have played through or liked,
// 0 to 1), times the share of the artist's plays that were not skips. An
// artist the user has never met weighs 1, so new artists still come up; an
// artist they keep skipping weighs well under 1.
//
// 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,
arg.ExcludeGenres,
)
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
}