From 48f288e2e55a4b02a1e77ed5419a38cf3d0c0740 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 3 Jul 2026 08:54:20 -0400 Subject: [PATCH] feat(mixes): tiered rebuilds for New for you + First listens (rule #131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6 --- internal/db/dbq/system_mixes.sql.go | 142 ++++++++++++++---- internal/db/queries/system_mixes.sql | 126 ++++++++++++---- internal/playlists/system.go | 15 ++ internal/playlists/system_mixes.go | 48 ++++-- internal/playlists/system_mixes_db_test.go | 163 +++++++++++++++++++++ internal/playlists/system_mixes_test.go | 104 +++++++++++++ 6 files changed, 524 insertions(+), 74 deletions(-) create mode 100644 internal/playlists/system_mixes_test.go diff --git a/internal/db/dbq/system_mixes.sql.go b/internal/db/dbq/system_mixes.sql.go index f43be18f..ce98bd19 100644 --- a/internal/db/dbq/system_mixes.sql.go +++ b/internal/db/dbq/system_mixes.sql.go @@ -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) diff --git a/internal/db/queries/system_mixes.sql b/internal/db/queries/system_mixes.sql index dd106b47..7503a122 100644 --- a/internal/db/queries/system_mixes.sql +++ b/internal/db/queries/system_mixes.sql @@ -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 diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 437498c0..2ad4495a 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -169,6 +169,21 @@ func pickKindForSeedTier(tier int32) string { } } +// pickKindForMixTier maps a tiered mix query's 1-based tier column +// (numbered to match rule #131's ladder directly) onto the pick-kind +// vocabulary. Distinct from pickKindForSeedTier, whose 0-based tiers +// count fallback steps of the seed pool rather than eligibility rungs. +func pickKindForMixTier(tier int32) string { + switch tier { + case 1: + return pickKindTier1 + case 2: + return pickKindTier2 + default: + return pickKindTier3 + } +} + const systemMixLength = 25 // systemMixWeights are the fixed scoring weights used by the cron worker. diff --git a/internal/playlists/system_mixes.go b/internal/playlists/system_mixes.go index 7a2c13e2..2ca6ef78 100644 --- a/internal/playlists/system_mixes.go +++ b/internal/playlists/system_mixes.go @@ -105,21 +105,39 @@ func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer { } } -// rotateForDay rotates pool left by a daily-deterministic offset so -// each day's downstream truncate-to-N surfaces a different slice of -// the pool while contiguous-block ordering inside the slice is -// preserved. Empty / single-element pools pass through unchanged. +// rotateForDay rotates the pool left by a daily-deterministic offset +// so each day's downstream truncate-to-N surfaces a different slice +// while contiguous-block ordering inside the slice is preserved. +// +// Rotation happens WITHIN each contiguous same-pick-kind block +// (#1267): tiered mixes arrive tier-ordered, and a whole-pool +// rotation would hoist tier-3 filler above tier-1's exact fits. +// Untiered pools are a single block, which reduces to the original +// whole-pool rotation (same seed, same first draw). Empty / +// single-element pools pass through unchanged. func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack { n := len(pool) if n <= 1 { return pool } rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) - offset := rng.Intn(n) - rotated := make([]discoverTrack, 0, n) - rotated = append(rotated, pool[offset:]...) - rotated = append(rotated, pool[:offset]...) - return rotated + out := make([]discoverTrack, 0, n) + for start := 0; start < n; { + end := start + 1 + for end < n && pool[end].PickKind == pool[start].PickKind { + end++ + } + block := pool[start:end] + if len(block) > 1 { + offset := rng.Intn(len(block)) + out = append(out, block[offset:]...) + out = append(out, block[:offset]...) + } else { + out = append(out, block...) + } + start = end + } + return out } // discoveryMixSpecs is the concrete spec list used by the registry in @@ -173,7 +191,10 @@ var discoveryMixSpecs = []discoveryMixSpec{ } out := make([]discoverTrack, len(rows)) for i, r := range rows { - out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} + out[i] = discoverTrack{ + ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID, + PickKind: pickKindForMixTier(r.Tier), + } } return out, nil }, @@ -207,7 +228,10 @@ var discoveryMixSpecs = []discoveryMixSpec{ } out := make([]discoverTrack, len(rows)) for i, r := range rows { - out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} + out[i] = discoverTrack{ + ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID, + PickKind: pickKindForMixTier(r.Tier), + } } return out, nil }, @@ -244,7 +268,7 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate { } tracks := make([]rankedCandidate, len(pool)) for i, t := range pool { - tracks[i] = rankedCandidate{TrackID: t.ID} + tracks[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind} } return tracks } diff --git a/internal/playlists/system_mixes_db_test.go b/internal/playlists/system_mixes_db_test.go index ae6d6c0d..a2a65c09 100644 --- a/internal/playlists/system_mixes_db_test.go +++ b/internal/playlists/system_mixes_db_test.go @@ -57,6 +57,54 @@ func idSet[T any](rows []T, id func(T) pgtype.UUID) map[pgtype.UUID]bool { return out } +// seedPlayWithDuration inserts a completed play with an explicit +// duration_played_ms — the "attempted >= 30s" queries key off it. +func seedPlayWithDuration(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, durationMs int32) { + t.Helper() + _, err := pool.Exec(context.Background(), ` + WITH s AS ( + INSERT INTO play_sessions (user_id, started_at, last_event_at) + VALUES ($1, $3, $3) + RETURNING id + ) + INSERT INTO play_events (user_id, track_id, session_id, started_at, duration_played_ms) + SELECT $1, $2, s.id, $3, $4 FROM s + `, userID, trackID, startedAt, durationMs) + if err != nil { + t.Fatalf("seed play with duration: %v", err) + } +} + +// seedAlbumForArtist creates a fresh album under an existing artist — +// the tiered album queries need several albums sharing one artist_id. +func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, title string, artistID pgtype.UUID) pgtype.UUID { + t.Helper() + al, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: title, SortTitle: title, ArtistID: artistID, + }) + if err != nil { + t.Fatalf("seed album for artist: %v", err) + } + return al.ID +} + +func backdateAlbum(t *testing.T, pool *pgxpool.Pool, albumID pgtype.UUID, to time.Time) { + t.Helper() + if _, err := pool.Exec(context.Background(), + `UPDATE albums SET created_at = $2 WHERE id = $1`, albumID, to); err != nil { + t.Fatalf("backdate album: %v", err) + } +} + +func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) { + t.Helper() + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`, + userID, artistID); err != nil { + t.Fatalf("like artist: %v", err) + } +} + func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) { // #1257: eligibility used to count only unskipped plays, so a track // skipped repeatedly (and never finished) read as "barely heard". @@ -133,6 +181,121 @@ func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) { } } +func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) { + // #1267: tiered eligibility with consume-on-play. Tier 1 = fresh + // affinity albums; tier 2 = affinity albums from the wider 90-day + // window; tier 3 = fresh additions without affinity; any attempted + // (>=30s) track consumes the album out of the mix entirely. + pool := newPool(t) + q := dbq.New(pool) + u := seedUser(t, pool, "nfy1") + now := time.Now().UTC() + + // Affinity artist via a like; anchor track establishes the artist. + anchor := seedTrack(t, pool, "nfy1-anchor", "nfy1-artistX") + likeArtist(t, pool, u.ID, anchor.ArtistID) + backdateAlbum(t, pool, anchor.AlbumID, now.Add(-200*24*time.Hour)) // out of every window + + // Fresh affinity album → tier 1. + freshAlbum := seedAlbumForArtist(t, pool, "nfy1-fresh", anchor.ArtistID) + freshTrack := seedTrackForArtist(t, pool, "nfy1-fresh-1", freshAlbum, anchor.ArtistID) + // Affinity album added 60 days ago → tier 2. + olderAlbum := seedAlbumForArtist(t, pool, "nfy1-older", anchor.ArtistID) + olderTrack := seedTrackForArtist(t, pool, "nfy1-older-1", olderAlbum, anchor.ArtistID) + backdateAlbum(t, pool, olderAlbum, now.Add(-60*24*time.Hour)) + // Fresh album by an unrelated artist → tier 3. + otherTrack := seedTrack(t, pool, "nfy1-other-1", "nfy1-artistY") + // Fresh affinity album with one attempted track → consumed, absent. + consumedAlbum := seedAlbumForArtist(t, pool, "nfy1-consumed", anchor.ArtistID) + consumedTrack := seedTrackForArtist(t, pool, "nfy1-consumed-1", consumedAlbum, anchor.ArtistID) + seedPlayWithDuration(t, pool, u.ID, consumedTrack.ID, now.Add(-time.Hour), 35_000) + // Affinity album added 120 days ago → outside every window, absent. + ancientAlbum := seedAlbumForArtist(t, pool, "nfy1-ancient", anchor.ArtistID) + ancientTrack := seedTrackForArtist(t, pool, "nfy1-ancient-1", ancientAlbum, anchor.ArtistID) + backdateAlbum(t, pool, ancientAlbum, now.Add(-120*24*time.Hour)) + + rows, err := q.ListNewForYouTracks(context.Background(), u.ID) + if err != nil { + t.Fatalf("ListNewForYouTracks: %v", err) + } + tiers := map[pgtype.UUID]int32{} + for _, r := range rows { + tiers[r.ID] = r.Tier + } + if got := tiers[freshTrack.ID]; got != 1 { + t.Errorf("fresh affinity album tier = %d, want 1", got) + } + if got := tiers[olderTrack.ID]; got != 2 { + t.Errorf("60-day affinity album tier = %d, want 2", got) + } + if got := tiers[otherTrack.ID]; got != 3 { + t.Errorf("fresh non-affinity album tier = %d, want 3", got) + } + if _, in := tiers[consumedTrack.ID]; in { + t.Error("album with an attempted track must be consumed out of the mix") + } + if _, in := tiers[ancientTrack.ID]; in { + t.Error("album added 120 days ago must be outside every window") + } +} + +func TestListFirstListensTracks_AttemptThreshold(t *testing.T) { + // #1268: "attempted" is track-level at >=30s. A 2-second brush no + // longer disqualifies an album; barely-attempted albums step back + // to tier 2 with the attempted tracks themselves excluded. + pool := newPool(t) + q := dbq.New(pool) + u := seedUser(t, pool, "fl1") + now := time.Now().UTC() + + // Never touched → tier 1. + untouched := seedTrack(t, pool, "fl1-untouched-1", "fl1-artistA") + // One 2-second accidental brush → still tier 1 (the headline bug). + brushed := seedTrack(t, pool, "fl1-brushed-1", "fl1-artistB") + seedPlayWithDuration(t, pool, u.ID, brushed.ID, now.Add(-time.Hour), 2_000) + // 4-track album with one attempted (25%) → tier 2, attempted track + // excluded, siblings present. + barely1 := seedTrack(t, pool, "fl1-barely-1", "fl1-artistC") + barely2 := seedTrackForArtist(t, pool, "fl1-barely-2", barely1.AlbumID, barely1.ArtistID) + barely3 := seedTrackForArtist(t, pool, "fl1-barely-3", barely1.AlbumID, barely1.ArtistID) + barely4 := seedTrackForArtist(t, pool, "fl1-barely-4", barely1.AlbumID, barely1.ArtistID) + seedPlayWithDuration(t, pool, u.ID, barely1.ID, now.Add(-time.Hour), 40_000) + // 2-track album with one attempted (50%) → past the brush + // threshold, absent entirely. + explored1 := seedTrack(t, pool, "fl1-explored-1", "fl1-artistD") + explored2 := seedTrackForArtist(t, pool, "fl1-explored-2", explored1.AlbumID, explored1.ArtistID) + seedPlayWithDuration(t, pool, u.ID, explored1.ID, now.Add(-time.Hour), 40_000) + + rows, err := q.ListFirstListensTracks(context.Background(), u.ID) + if err != nil { + t.Fatalf("ListFirstListensTracks: %v", err) + } + tiers := map[pgtype.UUID]int32{} + for _, r := range rows { + tiers[r.ID] = r.Tier + } + if got := tiers[untouched.ID]; got != 1 { + t.Errorf("untouched album tier = %d, want 1", got) + } + if got := tiers[brushed.ID]; got != 1 { + t.Errorf("2-second brush must not disqualify: tier = %d, want 1", got) + } + if _, in := tiers[barely1.ID]; in { + t.Error("the attempted track itself is not a first listen") + } + for _, tr := range []dbq.Track{barely2, barely3, barely4} { + if got := tiers[tr.ID]; got != 2 { + t.Errorf("sibling of attempted track tier = %d, want 2", got) + } + } + if _, in := tiers[explored1.ID]; in { + t.Error("half-attempted album must be excluded (attempted track)") + } + if _, in := tiers[explored2.ID]; in { + t.Error("half-attempted album must be excluded (sibling)") + } +} + func TestListRediscoverTracks_SkippedResurfacingCoolsDown(t *testing.T) { // #1258: a skip on a rediscover-sourced play is a declined // invitation — the track sits out ~90 days instead of re-qualifying diff --git a/internal/playlists/system_mixes_test.go b/internal/playlists/system_mixes_test.go new file mode 100644 index 00000000..64e863d0 --- /dev/null +++ b/internal/playlists/system_mixes_test.go @@ -0,0 +1,104 @@ +package playlists + +import ( + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +func mixTrack(b byte, kind string) discoverTrack { + return discoverTrack{ID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true}, PickKind: kind} +} + +func idsOf(pool []discoverTrack) []byte { + out := make([]byte, len(pool)) + for i, t := range pool { + out[i] = t.ID.Bytes[0] + } + return out +} + +func TestRotateForDay_RotatesWithinTierBlocks(t *testing.T) { + // Tiered pools arrive tier-ordered; rotation must vary the daily + // slice WITHIN each tier without hoisting tier-3 filler above + // tier-1's exact fits (#1267). + pool := []discoverTrack{ + mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier1), mixTrack(3, pickKindTier1), + mixTrack(4, pickKindTier2), mixTrack(5, pickKindTier2), + mixTrack(6, pickKindTier3), + } + u := pgtype.UUID{Bytes: [16]byte{42}, Valid: true} + got := rotateForDay(pool, u, "2026-07-03") + if len(got) != len(pool) { + t.Fatalf("len = %d, want %d", len(got), len(pool)) + } + wantKinds := []string{ + pickKindTier1, pickKindTier1, pickKindTier1, + pickKindTier2, pickKindTier2, pickKindTier3, + } + for i, k := range wantKinds { + if got[i].PickKind != k { + t.Fatalf("pos %d kind = %q, want %q (tier order broken: %v)", + i, got[i].PickKind, k, idsOf(got)) + } + } + // Each block is a rotation of its input: contiguity check for the + // 3-element tier-1 block (successor relation preserved cyclically). + wantOrder := map[byte]byte{1: 2, 2: 3, 3: 1} + for i := 0; i < 2; i++ { + if got[i+1].ID.Bytes[0] != wantOrder[got[i].ID.Bytes[0]] { + t.Errorf("tier1 block is not a rotation: %v", idsOf(got[:3])) + break + } + } + // Determinism within a day. + again := rotateForDay(pool, u, "2026-07-03") + for i := range got { + if got[i].ID != again[i].ID { + t.Fatal("rotation must be deterministic for the same (user, day)") + } + } +} + +func TestRotateForDay_UntieredPoolIsWholeRotation(t *testing.T) { + // Untiered pools (all PickKind "") are one block — the original + // whole-pool rotation semantics. + pool := []discoverTrack{mixTrack(1, ""), mixTrack(2, ""), mixTrack(3, ""), mixTrack(4, "")} + got := rotateForDay(pool, pgtype.UUID{Bytes: [16]byte{7}, Valid: true}, "2026-07-03") + if len(got) != 4 { + t.Fatalf("len = %d, want 4", len(got)) + } + wantNext := map[byte]byte{1: 2, 2: 3, 3: 4, 4: 1} + for i := 0; i < 3; i++ { + cur, next := got[i].ID.Bytes[0], got[i+1].ID.Bytes[0] + if wantNext[cur] != next { + t.Errorf("not a rotation of the input: %v", idsOf(got)) + break + } + } +} + +func TestFinishMix_PropagatesPickKind(t *testing.T) { + // Tier stamps ride discoverTrack through diversify/truncate into + // the rankedCandidates that insertSystemPlaylist persists (#1267). + pool := []discoverTrack{mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier2)} + got := finishMix(pool, false) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].PickKind != pickKindTier1 || got[1].PickKind != pickKindTier2 { + t.Errorf("pick kinds = %q, %q — want tier1, tier2", got[0].PickKind, got[1].PickKind) + } +} + +func TestPickKindForMixTier(t *testing.T) { + cases := []struct { + tier int32 + want string + }{{1, pickKindTier1}, {2, pickKindTier2}, {3, pickKindTier3}, {9, pickKindTier3}} + for _, c := range cases { + if got := pickKindForMixTier(c.tier); got != c.want { + t.Errorf("pickKindForMixTier(%d) = %q, want %q", c.tier, got, c.want) + } + } +}