7226dab9ff
The 2-week metrics review found Discover beating the manual baseline on skip rate — which for a discovery surface means it plays it safe. On a single-user server it's effectively dormant + crude-random, and the per-user taste profile (taste_profile_tags, #796) went unused (#1254 gap). Add a fourth Discover candidate bucket, ListTasteUnheardTracksForDiscover: unheard / non-liked / non-quarantined tracks ranked by summed taste-tag weight over tracks.genre (split like the radio tag_overlap arm), md5 tiebreak. Its picks are stamped pick_kind = 'taste_unheard' (migration 0041 widens the CHECK on playlist_tracks + play_events, rule #36). Rebalance the slot allocation 40/30/30 → taste_unheard 35 / dormant 30 / cross_user 20 / random 15, and lead the interleave with taste_unheard so a track shared with another bucket keeps the taste stamp and the targeted-novelty arm stays measurable. Metrics label "Taste-matched" + order entry added to the single server-side pickKindLabels map, so web and Android surface the new breakdown row with no client change. Cold start (empty taste_profile_tags) yields an empty taste bucket that redistributes to the survivors, so Discover still fills. Scribe #1488. Companion review outcomes: Songs-like starvation already fixed (#1255); For You v2 ratified as-is (fresh-injection cost disproven). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
280 lines
8.8 KiB
Go
280 lines
8.8 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 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 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 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
|
|
SELECT t.id, t.album_id, t.artist_id
|
|
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 = $1 AND trim(g_split.g) = nt.tag
|
|
WHERE nt.weight > 0
|
|
AND trim(g_split.g) <> ''
|
|
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
|
|
)
|
|
GROUP BY t.id, t.album_id, t.artist_id
|
|
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
|
LIMIT 120
|
|
`
|
|
|
|
type ListTasteUnheardTracksForDiscoverParams struct {
|
|
UserID pgtype.UUID
|
|
Column2 string
|
|
}
|
|
|
|
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'.
|
|
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
|
|
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
|
|
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
|
|
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
|
|
}
|