Files
minstrel/internal/db/dbq/discover.sql.go
T
bvandeusenandClaude Opus 5.5 9be5bbae3c
release / web (push) Successful in 1m37s
release / govulncheck (push) Successful in 53s
release / go (push) Successful in 2m7s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 6m34s
release / Build signed APK (releases and dev) (push) Successful in 6m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m36s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(discover): cap the taste-matched arm per album and artist before its LIMIT (#5356)
Summed tag weight rewards a track for carrying many of the user's tags, so
on the deploy two artists whose every track carries the whole lo-fi profile
took all 120 rows of the taste-unheard query. capByAlbumAndArtist ran after
the LIMIT and left 6, and the arm with the lowest skip rate (12% against
~23%) handed its slots to dormant and random.

The query now ranks within album, then within artist over what the album
cap kept, before the LIMIT: the same walk the Go cap makes, so the bucket
fills from as many artists as match. The caps come from the Go constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:24:20 -04:00

316 lines
10 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: discover.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const listCrossUserLikedTracksForDiscover = `-- name: ListCrossUserLikedTracksForDiscover :many
SELECT t.id, t.album_id, t.artist_id
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND gl.user_id != $1
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes ml
WHERE ml.user_id = $1 AND ml.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY md5(t.id::text || $2::text)
LIMIT 60
`
type ListCrossUserLikedTracksForDiscoverParams struct {
UserID pgtype.UUID
Column2 string
}
type ListCrossUserLikedTracksForDiscoverRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Tracks any OTHER user has liked, that this user hasn't played,
// liked, or had quarantined. On single-user servers this returns 0
// rows (the caller's slot redistribution rolls the deficit into the
// other two buckets).
// $1 = user_id, $2 = date string for md5 ordering.
//
// GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need
// not appear in the select list — Postgres rejects DISTINCT + ORDER BY
// by-expression at plan time (SQLSTATE 42P10), which previously caused
// the entire Discover build to fail silently.
func (q *Queries) ListCrossUserLikedTracksForDiscover(ctx context.Context, arg ListCrossUserLikedTracksForDiscoverParams) ([]ListCrossUserLikedTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listCrossUserLikedTracksForDiscover, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListCrossUserLikedTracksForDiscoverRow
for rows.Next() {
var i ListCrossUserLikedTracksForDiscoverRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDormantArtistTracksForDiscover = `-- name: ListDormantArtistTracksForDiscover :many
WITH user_artist_counts AS (
SELECT t.artist_id, COUNT(*) AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.was_skipped = false
GROUP BY t.artist_id
),
dormant_artists AS (
SELECT a.id
FROM artists a
LEFT JOIN user_artist_counts uac ON uac.artist_id = a.id
WHERE COALESCE(uac.play_count, 0) < 10
)
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN dormant_artists da ON da.id = t.artist_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
LIMIT 80
`
type ListDormantArtistTracksForDiscoverParams struct {
UserID pgtype.UUID
Column2 string
}
type ListDormantArtistTracksForDiscoverRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Discover playlist bucket queries. Each returns up to its LIMIT
// count of (track_id, album_id, artist_id) triples ordered
// daily-deterministically via md5(track_id || dateStr). The Go-side
// bucket allocator (internal/playlists/discover.go) applies
// per-album (<=2) and per-artist (<=3) caps then redistributes any
// bucket deficit equally across the others. LIMIT values are
// generous so the caps + redistribution have headroom.
// Tracks whose artist this user has played fewer than 10 times total.
// The artist threshold defines "dormant" — we want to surface the
// long tail of artists in the user's library that they rarely listen
// to. Excludes tracks the user has played, liked, or has quarantined.
// $1 = user_id, $2 = date string for md5 ordering.
func (q *Queries) ListDormantArtistTracksForDiscover(ctx context.Context, arg ListDormantArtistTracksForDiscoverParams) ([]ListDormantArtistTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listDormantArtistTracksForDiscover, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListDormantArtistTracksForDiscoverRow
for rows.Next() {
var i ListDormantArtistTracksForDiscoverRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRandomUnheardTracksForDiscover = `-- name: ListRandomUnheardTracksForDiscover :many
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
LIMIT 200
`
type ListRandomUnheardTracksForDiscoverParams struct {
UserID pgtype.UUID
Column2 string
}
type ListRandomUnheardTracksForDiscoverRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Random sample from the entire library. Same exclusion filters as
// the other two buckets. The generous LIMIT (200) gives the caller
// headroom for the per-album/artist caps and the slot redistribution.
// $1 = user_id, $2 = date string for md5 ordering.
func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg ListRandomUnheardTracksForDiscoverParams) ([]ListRandomUnheardTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listRandomUnheardTracksForDiscover, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListRandomUnheardTracksForDiscoverRow
for rows.Next() {
var i ListRandomUnheardTracksForDiscoverRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
WITH scored AS (
SELECT t.id, t.album_id, t.artist_id,
SUM(nt.weight) AS weight,
md5(t.id::text || $2::text) AS tiebreak
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $3 AND trim(g_split.g) = nt.tag
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $3
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $3 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $3 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
),
album_capped AS (
SELECT s.id, s.album_id, s.artist_id, s.weight, s.tiebreak,
row_number() OVER (PARTITION BY s.album_id ORDER BY s.weight DESC, s.tiebreak) AS album_rank
FROM scored s
),
artist_capped AS (
SELECT a.id, a.album_id, a.artist_id, a.weight, a.tiebreak, a.album_rank,
row_number() OVER (PARTITION BY a.artist_id ORDER BY a.weight DESC, a.tiebreak) AS artist_rank
FROM album_capped a
WHERE a.album_rank <= $4::int
)
SELECT c.id, c.album_id, c.artist_id
FROM artist_capped c
WHERE c.artist_rank <= $1::int
ORDER BY c.weight DESC, c.tiebreak
LIMIT 120
`
type ListTasteUnheardTracksForDiscoverParams struct {
MaxPerArtist int32
DateSeed string
UserID pgtype.UUID
MaxPerAlbum int32
}
type ListTasteUnheardTracksForDiscoverRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
// weight — "new to you, but your vibe" rather than the crude random arm.
// Genres live inline on tracks.genre as a delimited string, split the
// same way the radio tag_overlap arm does (regexp_split_to_table on
// [;,]). Same exclusion filters as the other buckets. Returns nothing
// when the user has no taste tags yet (cold start), so the caller
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
//
// The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed
// weight rewards a track for carrying many of the user's tags, so a few
// artists whose every track is tagged with the whole profile take every row
// of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and
// the best-performing arm handed its slots to the others. Ranking within
// album, then within artist over what the album cap kept, is the same walk
// capByAlbumAndArtist makes, so the caller's caps keep everything here.
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover,
arg.MaxPerArtist,
arg.DateSeed,
arg.UserID,
arg.MaxPerAlbum,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteUnheardTracksForDiscoverRow
for rows.Next() {
var i ListTasteUnheardTracksForDiscoverRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}