release / web (push) Successful in 1m40s
release / go (push) Successful in 2m5s
release / govulncheck (push) Successful in 19s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 4m40s
release / Build signed APK (push) Successful in 5m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
Within a genre both halves of the mix were a uniform daily draw: a song played forty times was no likelier than one played once, and the unheard 40% ignored who the artist was. ListGenreMixTracks now draws each half by a date-seeded weighted lottery (Efraimidis-Spirakis: smallest -ln(u)/weight wins), so the mix still changes daily and holds within a day. - Familiar: 1 + ln(1 + plays) + 1 if liked, times the share of plays that weren't skips. - Unheard: 1 + ln(1 + the artist's plays + 2 x its liked tracks) + 2 x the artist's best similarity to the user's own artists, times the artist's non-skip share. An unknown artist weighs 1, so new artists still come up. Which genres are offered, the exclusions and the familiar/unheard blend are unchanged. DB tests check the lean averaged over ten days, and that a stranger's tracks stay in the pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
271 lines
9.3 KiB
Go
271 lines
9.3 KiB
Go
// 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
|
||
),
|
||
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 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
|
||
}
|
||
|
||
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.
|
||
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
|
||
}
|