diff --git a/internal/db/dbq/discover.sql.go b/internal/db/dbq/discover.sql.go new file mode 100644 index 00000000..3b9ad897 --- /dev/null +++ b/internal/db/dbq/discover.sql.go @@ -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 +} diff --git a/internal/db/migrations/0021_discover_variant.down.sql b/internal/db/migrations/0021_discover_variant.down.sql new file mode 100644 index 00000000..d93aa89b --- /dev/null +++ b/internal/db/migrations/0021_discover_variant.down.sql @@ -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) + ); diff --git a/internal/db/migrations/0021_discover_variant.up.sql b/internal/db/migrations/0021_discover_variant.up.sql new file mode 100644 index 00000000..79af4416 --- /dev/null +++ b/internal/db/migrations/0021_discover_variant.up.sql @@ -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) + ); diff --git a/internal/db/queries/discover.sql b/internal/db/queries/discover.sql new file mode 100644 index 00000000..8d13392f --- /dev/null +++ b/internal/db/queries/discover.sql @@ -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;