48f288e2e5
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
343 lines
13 KiB
Go
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")
|
|
}
|
|
}
|