-- 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 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; -- 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. -- -- 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. 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; -- 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 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; -- name: ListTasteUnheardTracksForDiscover :many -- 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. WITH scored AS ( SELECT t.id, t.album_id, t.artist_id, SUM(nt.weight) AS weight, md5(t.id::text || sqlc.arg(date_seed)::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 = sqlc.arg(user_id) 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 = sqlc.arg(user_id) AND pe.track_id = t.id AND pe.was_skipped = false ) AND NOT EXISTS ( SELECT 1 FROM general_likes gl WHERE gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id ) AND NOT EXISTS ( SELECT 1 FROM lidarr_quarantine q WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id ) GROUP BY t.id, t.album_id, t.artist_id ), album_capped AS ( SELECT s.*, 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.*, 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 <= sqlc.arg(max_per_album)::int ) SELECT c.id, c.album_id, c.artist_id FROM artist_capped c WHERE c.artist_rank <= sqlc.arg(max_per_artist)::int ORDER BY c.weight DESC, c.tiebreak LIMIT 120;