feat(mixes): tiered rebuilds for New for you + First listens (rule #131)
Both mixes move from a single hard eligibility rule to the tiered ladder, with their tier stamped onto playlist_tracks.pick_kind via the #1270 provenance pipeline. New for you (#1267) — consume on play, degrade by stepping back: - "Consumed" = any track attempted >=30s; played albums leave the mix at the next build instead of crowding it until the calendar window expires. - Tier 1: unconsumed albums added <30d by direct-affinity artists. Tier 2: unconsumed affinity albums from the wider 30-90d window — added while you weren't looking. Tier 3: any unconsumed album added <90d, newest first. First listens (#1268) — track-level "attempted" threshold: - A 2-second accidental brush no longer disqualifies a whole album; "attempted" is duration_played_ms >= 30000 per track. - Tier 1: albums with zero attempted tracks. Tier 2: barely-attempted albums (<=25% of tracks reached 30s), minus the attempted tracks themselves. The artist-affinity ordering signal also moves to the >=30s definition so skip-only contact doesn't read as trust. Producer plumbing: fetch adapters map the tier column onto pick kinds, finishMix propagates PickKind into the persisted candidates, and rotateForDay now rotates within contiguous same-pick-kind blocks so daily rotation can't hoist tier-3 filler above tier-1's exact fits (untiered pools are one block — original behavior). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -95,31 +95,49 @@ func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracks
|
||||
}
|
||||
|
||||
const listFirstListensTracks = `-- name: ListFirstListensTracks :many
|
||||
WITH heard_albums AS (
|
||||
SELECT DISTINCT t.album_id
|
||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
WITH attempted AS (
|
||||
SELECT DISTINCT pe.track_id
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||
),
|
||||
played_artists AS (
|
||||
album_attempts AS (
|
||||
SELECT t.album_id,
|
||||
COUNT(a.track_id) AS attempted_count,
|
||||
COUNT(*) AS track_count
|
||||
FROM tracks t
|
||||
LEFT JOIN attempted a ON a.track_id = t.id
|
||||
GROUP BY t.album_id
|
||||
),
|
||||
attempted_artists AS (
|
||||
SELECT DISTINCT t.artist_id
|
||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||
),
|
||||
albums_tiered AS (
|
||||
SELECT aa.album_id,
|
||||
CASE
|
||||
WHEN aa.attempted_count = 0 THEN 1
|
||||
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
|
||||
END AS tier
|
||||
FROM album_attempts aa
|
||||
)
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
|
||||
JOIN albums_tiered alt ON alt.album_id = al.id
|
||||
WHERE alt.tier IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.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
|
||||
alt.tier,
|
||||
(CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM general_likes_artists gla
|
||||
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
|
||||
) THEN 0
|
||||
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1
|
||||
WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
|
||||
ELSE 2
|
||||
END),
|
||||
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||
@@ -130,12 +148,25 @@ type ListFirstListensTracksRow struct {
|
||||
ID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Tier int32
|
||||
}
|
||||
|
||||
// #423 First Listens: albums the user has never played any track of.
|
||||
// Tiered: liked-artist albums first, then played-artist albums, then
|
||||
// the rest — album-coherent within each tier. Not diversity-capped
|
||||
// (whole-album discovery). $1 user_id.
|
||||
// #423 First Listens: songs the user has never heard or even
|
||||
// attempted. "Attempted" is track-level with a >=30s listen threshold
|
||||
// (#1268, operator decision 2026-07-03) — the old version disqualified
|
||||
// a whole album on ANY play_event, so a 2-second accidental brush
|
||||
// banished it permanently.
|
||||
//
|
||||
// tier 1 the exact desire: albums with ZERO attempted tracks
|
||||
// tier 2 step back: barely-attempted albums (<=25% of tracks
|
||||
// reached 30s) — brushed, never explored. The already-
|
||||
// attempted tracks themselves are excluded; they're not
|
||||
// first listens.
|
||||
//
|
||||
// Within each tier, affinity-ordered as before: liked artist, then
|
||||
// attempted-played artist (>=30s — skip-only contact isn't trust),
|
||||
// then the rest; album-coherent. The tier lands on
|
||||
// playlist_tracks.pick_kind via the producer. $1 user_id.
|
||||
func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) {
|
||||
rows, err := q.db.Query(ctx, listFirstListensTracks, userID)
|
||||
if err != nil {
|
||||
@@ -145,7 +176,12 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
|
||||
var items []ListFirstListensTracksRow
|
||||
for rows.Next() {
|
||||
var i ListFirstListensTracksRow
|
||||
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AlbumID,
|
||||
&i.ArtistID,
|
||||
&i.Tier,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
@@ -157,39 +193,76 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
|
||||
}
|
||||
|
||||
const listNewForYouTracks = `-- name: ListNewForYouTracks :many
|
||||
WITH affinity_artists AS (
|
||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
||||
WITH attempted AS (
|
||||
SELECT DISTINCT pe.track_id
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||
),
|
||||
consumed_albums AS (
|
||||
SELECT DISTINCT t.album_id
|
||||
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||
),
|
||||
affinity_artists AS (
|
||||
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||
UNION
|
||||
SELECT t.artist_id
|
||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||
GROUP BY t.artist_id
|
||||
HAVING COUNT(*) >= 3
|
||||
),
|
||||
albums_tiered AS (
|
||||
SELECT al.id AS album_id, al.created_at,
|
||||
CASE
|
||||
WHEN al.created_at >= now() - interval '30 days'
|
||||
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
|
||||
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
|
||||
ELSE 3
|
||||
END AS tier
|
||||
FROM albums al
|
||||
WHERE al.created_at >= now() - interval '90 days'
|
||||
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
|
||||
)
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN affinity_artists aa ON aa.artist_id = al.artist_id
|
||||
WHERE al.created_at >= now() - interval '30 days'
|
||||
AND NOT EXISTS (
|
||||
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||
LIMIT 200
|
||||
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
|
||||
t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||
LIMIT 300
|
||||
`
|
||||
|
||||
type ListNewForYouTracksRow struct {
|
||||
ID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Tier int32
|
||||
}
|
||||
|
||||
// #421 New for you: tracks from albums added in the last 30 days
|
||||
// whose artist the user has liked OR played (>=3 non-skip). Album-
|
||||
// coherent (newest album first, then disc/track) — the producer does
|
||||
// NOT diversity-cap these; they're meant as whole-album discovery.
|
||||
// $1 user_id.
|
||||
// #421 New for you, tiered per project rule #131 (#1267). The old
|
||||
// single hard rule (added <30d AND direct affinity) had two failure
|
||||
// modes with one root: no notion of consumption — the album you
|
||||
// devoured in week one crowded the mix for three more weeks, and
|
||||
// after a quiet month nothing qualified at all.
|
||||
//
|
||||
// "Consumed" = any track attempted for >=30s (the First Listens
|
||||
// threshold, #1268): once you've meaningfully engaged, the album is
|
||||
// no longer news. Applies to every tier — played albums leave the
|
||||
// mix the next build.
|
||||
//
|
||||
// tier 1 the exact desire: unconsumed albums added <30d by
|
||||
// direct-affinity artists (liked, or >=3 attempted plays)
|
||||
// tier 2 step back a little: unconsumed affinity albums from the
|
||||
// wider 30-90d window — added while you weren't looking
|
||||
// tier 3 step back more: any unconsumed album added <90d
|
||||
// regardless of affinity, newest first
|
||||
//
|
||||
// Album-coherent within tiers (newest album first, then disc/track);
|
||||
// the producer fills top-down and rotates within tier blocks. The
|
||||
// tier lands on playlist_tracks.pick_kind so metrics can price what
|
||||
// each step-back trades away. $1 user_id.
|
||||
func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) {
|
||||
rows, err := q.db.Query(ctx, listNewForYouTracks, userID)
|
||||
if err != nil {
|
||||
@@ -199,7 +272,12 @@ func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) (
|
||||
var items []ListNewForYouTracksRow
|
||||
for rows.Next() {
|
||||
var i ListNewForYouTracksRow
|
||||
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AlbumID,
|
||||
&i.ArtistID,
|
||||
&i.Tier,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
|
||||
@@ -95,31 +95,67 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
LIMIT 200;
|
||||
|
||||
-- name: ListNewForYouTracks :many
|
||||
-- #421 New for you: tracks from albums added in the last 30 days
|
||||
-- whose artist the user has liked OR played (>=3 non-skip). Album-
|
||||
-- coherent (newest album first, then disc/track) — the producer does
|
||||
-- NOT diversity-cap these; they're meant as whole-album discovery.
|
||||
-- $1 user_id.
|
||||
WITH affinity_artists AS (
|
||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
||||
-- #421 New for you, tiered per project rule #131 (#1267). The old
|
||||
-- single hard rule (added <30d AND direct affinity) had two failure
|
||||
-- modes with one root: no notion of consumption — the album you
|
||||
-- devoured in week one crowded the mix for three more weeks, and
|
||||
-- after a quiet month nothing qualified at all.
|
||||
--
|
||||
-- "Consumed" = any track attempted for >=30s (the First Listens
|
||||
-- threshold, #1268): once you've meaningfully engaged, the album is
|
||||
-- no longer news. Applies to every tier — played albums leave the
|
||||
-- mix the next build.
|
||||
--
|
||||
-- tier 1 the exact desire: unconsumed albums added <30d by
|
||||
-- direct-affinity artists (liked, or >=3 attempted plays)
|
||||
-- tier 2 step back a little: unconsumed affinity albums from the
|
||||
-- wider 30-90d window — added while you weren't looking
|
||||
-- tier 3 step back more: any unconsumed album added <90d
|
||||
-- regardless of affinity, newest first
|
||||
--
|
||||
-- Album-coherent within tiers (newest album first, then disc/track);
|
||||
-- the producer fills top-down and rotates within tier blocks. The
|
||||
-- tier lands on playlist_tracks.pick_kind so metrics can price what
|
||||
-- each step-back trades away. $1 user_id.
|
||||
WITH attempted AS (
|
||||
SELECT DISTINCT pe.track_id
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||
),
|
||||
consumed_albums AS (
|
||||
SELECT DISTINCT t.album_id
|
||||
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||
),
|
||||
affinity_artists AS (
|
||||
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||
UNION
|
||||
SELECT t.artist_id
|
||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||
GROUP BY t.artist_id
|
||||
HAVING COUNT(*) >= 3
|
||||
),
|
||||
albums_tiered AS (
|
||||
SELECT al.id AS album_id, al.created_at,
|
||||
CASE
|
||||
WHEN al.created_at >= now() - interval '30 days'
|
||||
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
|
||||
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
|
||||
ELSE 3
|
||||
END AS tier
|
||||
FROM albums al
|
||||
WHERE al.created_at >= now() - interval '90 days'
|
||||
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
|
||||
)
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN affinity_artists aa ON aa.artist_id = al.artist_id
|
||||
WHERE al.created_at >= now() - interval '30 days'
|
||||
AND NOT EXISTS (
|
||||
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||
LIMIT 200;
|
||||
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
|
||||
t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||
LIMIT 300;
|
||||
|
||||
-- name: ListOnThisDayTracks :many
|
||||
-- #422 On This Day: tracks the user played around this calendar date
|
||||
@@ -159,35 +195,65 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
LIMIT 200;
|
||||
|
||||
-- name: ListFirstListensTracks :many
|
||||
-- #423 First Listens: albums the user has never played any track of.
|
||||
-- Tiered: liked-artist albums first, then played-artist albums, then
|
||||
-- the rest — album-coherent within each tier. Not diversity-capped
|
||||
-- (whole-album discovery). $1 user_id.
|
||||
WITH heard_albums AS (
|
||||
SELECT DISTINCT t.album_id
|
||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
-- #423 First Listens: songs the user has never heard or even
|
||||
-- attempted. "Attempted" is track-level with a >=30s listen threshold
|
||||
-- (#1268, operator decision 2026-07-03) — the old version disqualified
|
||||
-- a whole album on ANY play_event, so a 2-second accidental brush
|
||||
-- banished it permanently.
|
||||
--
|
||||
-- tier 1 the exact desire: albums with ZERO attempted tracks
|
||||
-- tier 2 step back: barely-attempted albums (<=25% of tracks
|
||||
-- reached 30s) — brushed, never explored. The already-
|
||||
-- attempted tracks themselves are excluded; they're not
|
||||
-- first listens.
|
||||
--
|
||||
-- Within each tier, affinity-ordered as before: liked artist, then
|
||||
-- attempted-played artist (>=30s — skip-only contact isn't trust),
|
||||
-- then the rest; album-coherent. The tier lands on
|
||||
-- playlist_tracks.pick_kind via the producer. $1 user_id.
|
||||
WITH attempted AS (
|
||||
SELECT DISTINCT pe.track_id
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||
),
|
||||
played_artists AS (
|
||||
album_attempts AS (
|
||||
SELECT t.album_id,
|
||||
COUNT(a.track_id) AS attempted_count,
|
||||
COUNT(*) AS track_count
|
||||
FROM tracks t
|
||||
LEFT JOIN attempted a ON a.track_id = t.id
|
||||
GROUP BY t.album_id
|
||||
),
|
||||
attempted_artists AS (
|
||||
SELECT DISTINCT t.artist_id
|
||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||
),
|
||||
albums_tiered AS (
|
||||
SELECT aa.album_id,
|
||||
CASE
|
||||
WHEN aa.attempted_count = 0 THEN 1
|
||||
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
|
||||
END AS tier
|
||||
FROM album_attempts aa
|
||||
)
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
|
||||
JOIN albums_tiered alt ON alt.album_id = al.id
|
||||
WHERE alt.tier IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.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
|
||||
alt.tier,
|
||||
(CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM general_likes_artists gla
|
||||
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
|
||||
) THEN 0
|
||||
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1
|
||||
WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
|
||||
ELSE 2
|
||||
END),
|
||||
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||
|
||||
Reference in New Issue
Block a user