Files
minstrel/internal/db/queries/discover.sql
T
bvandeusen 7226dab9ff
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m38s
feat(discover): taste-targeted novelty bucket + rebalance toward discovery
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>
2026-07-13 18:16:04 -04:00

139 lines
5.1 KiB
SQL

-- 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.
--
-- 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 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 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'.
-- $1 = user_id, $2 = date string for md5 tiebreak ordering.
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;