Files
minstrel/internal/playlists/system_mixes_db_test.go
bvandeusen 48f288e2e5
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m39s
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
2026-07-03 08:54:20 -04:00

343 lines
13 KiB
Go

package playlists_test
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// seedTrackForArtist creates another track under an existing artist +
// album pair. seedTrack's mbid-less upsert never dedupes artists, and
// Deep Cuts' affinity rule needs several tracks sharing one artist_id.
func seedTrackForArtist(t *testing.T, pool *pgxpool.Pool, title string, albumID, artistID pgtype.UUID) dbq.Track {
t.Helper()
track, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: albumID, ArtistID: artistID,
DurationMs: 1000,
FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("%s.mp3", title)),
FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("seed track for artist: %v", err)
}
return track
}
// seedSourcedPlayEvent is seedPlayEvent plus a source tag — needed by
// the Rediscover cooldown, which keys off rediscover-sourced skips.
func seedSourcedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool, source string) {
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, was_skipped, source)
SELECT $1, $2, s.id, $3, $4, $5 FROM s
`, userID, trackID, startedAt, wasSkipped, source)
if err != nil {
t.Fatalf("seed sourced play_event: %v", err)
}
}
// idSet collapses candidate rows into a lookup by track id.
func idSet[T any](rows []T, id func(T) pgtype.UUID) map[pgtype.UUID]bool {
out := make(map[pgtype.UUID]bool, len(rows))
for _, r := range rows {
out[id(r)] = true
}
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".
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "dc1")
now := time.Now().UTC()
// Affinity artist: 5 non-skip plays on the heavy track.
heavy := seedTrack(t, pool, "dc1-heavy", "dc1-artist")
for p := 0; p < 5; p++ {
seedPlayEvent(t, pool, u.ID, heavy.ID, now.Add(-time.Duration(p+1)*time.Hour), false)
}
// Actively rejected: two skips, zero completed plays.
rejected := seedTrackForArtist(t, pool, "dc1-rejected", heavy.AlbumID, heavy.ArtistID)
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-2*time.Hour), true)
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-3*time.Hour), true)
// Genuinely unexplored.
unheard := seedTrackForArtist(t, pool, "dc1-unheard", heavy.AlbumID, heavy.ArtistID)
// One accidental skip stays below the threshold.
oneSkip := seedTrackForArtist(t, pool, "dc1-oneskip", heavy.AlbumID, heavy.ArtistID)
seedPlayEvent(t, pool, u.ID, oneSkip.ID, now.Add(-4*time.Hour), true)
rows, err := q.ListDeepCutsTracks(context.Background(), dbq.ListDeepCutsTracksParams{
UserID: u.ID, Column2: "2026-07-03",
})
if err != nil {
t.Fatalf("ListDeepCutsTracks: %v", err)
}
got := idSet(rows, func(r dbq.ListDeepCutsTracksRow) pgtype.UUID { return r.ID })
if got[rejected.ID] {
t.Error("twice-skipped track qualified as a deep cut; skips must disqualify")
}
if !got[unheard.ID] {
t.Error("unexplored track missing from deep cuts")
}
if !got[oneSkip.ID] {
t.Error("single accidental skip must not banish a track")
}
if got[heavy.ID] {
t.Error("heavily-played track must not be a deep cut")
}
}
func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
// #1256: plain ABS on day-of-year made Dec 28 vs Jan 3 read as 359
// days apart. The build date parameter anchors "today" so the wrap
// is testable at any run date.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "otd1")
// Played Dec 28, 2025 — 6 circular days from the Jan 3 build date.
winter := seedTrack(t, pool, "otd1-winter", "otd1-artist")
seedPlayEvent(t, pool, u.ID, winter.ID,
time.Date(2025, 12, 28, 12, 0, 0, 0, time.UTC), false)
// Played mid-March — far outside the ±10-day window.
spring := seedTrack(t, pool, "otd1-spring", "otd1-artist")
seedPlayEvent(t, pool, u.ID, spring.ID,
time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), false)
rows, err := q.ListOnThisDayTracks(context.Background(), dbq.ListOnThisDayTracksParams{
UserID: u.ID, Column2: "2026-01-03",
})
if err != nil {
t.Fatalf("ListOnThisDayTracks: %v", err)
}
got := idSet(rows, func(r dbq.ListOnThisDayTracksRow) pgtype.UUID { return r.ID })
if !got[winter.ID] {
t.Error("play 6 calendar days across the year boundary must qualify (circular DOY)")
}
if got[spring.ID] {
t.Error("play ~70 days away must not qualify")
}
}
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
// the next day. Skips from other surfaces don't cool down, and the
// cooldown expires.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "rd1")
now := time.Now().UTC()
eligible := func(name string, playsAge time.Duration) dbq.Track {
tk := seedTrack(t, pool, name, name+"-artist")
for p := 0; p < 3; p++ {
seedPlayEvent(t, pool, u.ID, tk.ID,
now.Add(-playsAge-time.Duration(p)*time.Hour), false)
}
return tk
}
// Declined 5 days ago from Rediscover itself → cooling down.
declined := eligible("rd1-declined", 60*24*time.Hour)
seedSourcedPlayEvent(t, pool, u.ID, declined.ID, now.Add(-5*24*time.Hour), true, "rediscover")
// Skipped 5 days ago, but from a different surface → still eligible.
otherSkip := eligible("rd1-otherskip", 60*24*time.Hour)
seedSourcedPlayEvent(t, pool, u.ID, otherSkip.ID, now.Add(-5*24*time.Hour), true, "radio")
// Declined 100 days ago → cooldown expired, eligible again.
lapsed := eligible("rd1-lapsed", 120*24*time.Hour)
seedSourcedPlayEvent(t, pool, u.ID, lapsed.ID, now.Add(-100*24*time.Hour), true, "rediscover")
rows, err := q.ListRediscoverTracks(context.Background(), u.ID)
if err != nil {
t.Fatalf("ListRediscoverTracks: %v", err)
}
got := idSet(rows, func(r dbq.ListRediscoverTracksRow) pgtype.UUID { return r.ID })
if got[declined.ID] {
t.Error("track declined from rediscover 5 days ago must be cooling down")
}
if !got[otherSkip.ID] {
t.Error("skip from a non-rediscover surface must not cool the track down")
}
if !got[lapsed.ID] {
t.Error("cooldown must expire after ~90 days")
}
}