feat(db/m7-discover): migration 0021 + bucket queries
Adds 'discover' as an accepted system_variant on the playlists table — alongside 'for_you' and 'songs_like_artist' — and three sqlc bucket queries that back the new playlist's daily candidate selection. The three buckets surface tracks the user hasn't played and hasn't liked: - Dormant artists: artists this user has played < 10 times total. Surfaces the long tail of their library. - Cross-user likes: tracks any OTHER user has liked but this one hasn't engaged with. Empty on single-user servers; the Go-side allocator redistributes the deficit across the other two buckets. - Random unheard: pure random sample as the safety net and the cold-start fallback. All three share exclusion filters: not-played by this user, not liked by this user, not in lidarr_quarantine for this user. All order by md5(track_id || dateStr) for daily determinism — same pattern as the existing tieBreakHash logic in system.go. LIMIT values are generous (80/60/200) so the per-album (<=2) / per-artist (<=3) caps and slot redistribution in T2 have headroom. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
// 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 DISTINCT 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
|
||||
)
|
||||
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.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Restore the prior tighter CHECK constraints (pre-discover).
|
||||
-- This will fail if any rows currently have system_variant = 'discover';
|
||||
-- operator must clear those rows manually before rolling back.
|
||||
|
||||
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'))
|
||||
);
|
||||
|
||||
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 = 'songs_like_artist' AND seed_artist_id IS NOT NULL)
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Adds 'discover' as an accepted system_variant alongside 'for_you'
|
||||
-- and 'songs_like_artist'. Discover has no seed_artist_id (like
|
||||
-- for_you), so the variant->seed coherence check is extended to
|
||||
-- accept (system_variant = 'discover' AND seed_artist_id IS NULL).
|
||||
--
|
||||
-- Drops + re-adds both CHECK constraints; PostgreSQL doesn't have
|
||||
-- ALTER CONSTRAINT for CHECK so this is the standard pattern.
|
||||
|
||||
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'))
|
||||
);
|
||||
|
||||
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 = 'songs_like_artist' AND seed_artist_id IS NOT NULL)
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
-- 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.
|
||||
|
||||
-- name: ListDormantArtistTracksForDiscover :many
|
||||
-- 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.
|
||||
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;
|
||||
|
||||
-- name: ListCrossUserLikedTracksForDiscover :many
|
||||
-- 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.
|
||||
SELECT DISTINCT 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
|
||||
)
|
||||
ORDER BY md5(t.id::text || $2::text)
|
||||
LIMIT 60;
|
||||
|
||||
-- name: ListRandomUnheardTracksForDiscover :many
|
||||
-- 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.
|
||||
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;
|
||||
Reference in New Issue
Block a user