package playlists_test import ( "context" "io" "log/slog" "sync" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" ) func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // seedPlayEvent inserts a play_events row with the given was_skipped flag, // at startedAt, for the given user/track. Uses raw SQL because the sqlc // `InsertPlayEvent` doesn't accept was_skipped (that's set by an UPDATE). func seedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool) { t.Helper() // play_events.session_id has a FK to play_sessions; create a parent // session in the same statement (a random UUID violates the FK). _, 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) SELECT $1, $2, s.id, $3, $4 FROM s `, userID, trackID, startedAt, wasSkipped) if err != nil { t.Fatalf("seed play_event: %v", err) } } // seedQuarantine inserts a lidarr_quarantine row for (user, track). func seedQuarantine(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) { t.Helper() _, err := pool.Exec(context.Background(), ` INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'other') `, userID, trackID) if err != nil { t.Fatalf("seed quarantine: %v", err) } } // seedActiveLibrary creates a user with N played tracks across M artists, // all within the last 7 days. Returns the user and the slice of tracks. // Each track gets ≥3 unskipped plays so PickSeedArtists has a real signal. func seedActiveLibrary(t *testing.T, pool *pgxpool.Pool, name string, numArtists, tracksPerArtist int) (dbq.User, []dbq.Track) { t.Helper() u := seedUser(t, pool, name) now := time.Now().UTC() var allTracks []dbq.Track for a := 0; a < numArtists; a++ { artistName := name + "-artist-" + string(rune('A'+a)) for k := 0; k < tracksPerArtist; k++ { tk := seedTrack(t, pool, name+"-track-"+string(rune('A'+a))+string(rune('0'+k)), artistName) allTracks = append(allTracks, tk) // 3 unskipped plays per track to populate engagement stats. for p := 0; p < 3; p++ { seedPlayEvent(t, pool, u.ID, tk.ID, now.Add(-time.Duration(a*10+k+p)*time.Hour), false) } } } return u, allTracks } func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) { pool := newPool(t) logger := discardLogger() u, _ := seedActiveLibrary(t, pool, "act1", 4, 3) if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil { t.Fatalf("build: %v", err) } rows, err := dbq.New(pool).ListPlaylistsByUserAndKind(context.Background(), dbq.ListPlaylistsByUserAndKindParams{ UserID: u.ID, Column2: "system", }) if err != nil { t.Fatalf("list: %v", err) } if len(rows) == 0 { t.Fatalf("expected at least one system playlist; got 0") } var hasForYou, hasSongsLike bool for _, r := range rows { if r.Kind != "system" { t.Errorf("row %s: kind=%q want 'system'", uuidString(r.ID), r.Kind) } if r.SystemVariant == nil { t.Errorf("row %s: nil system_variant", uuidString(r.ID)) continue } switch *r.SystemVariant { case "for_you": hasForYou = true if r.SeedArtistID.Valid { t.Errorf("for_you row should have NULL seed_artist_id; got valid") } case "songs_like_artist": hasSongsLike = true if !r.SeedArtistID.Valid { t.Errorf("songs_like_artist row should have non-NULL seed_artist_id") } case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens": // Discover + the #411 discovery mixes are all seedless — // no seed_artist_id required. default: t.Errorf("unknown system_variant=%q", *r.SystemVariant) } if r.TrackCount == 0 { t.Errorf("row %s: empty track_count", uuidString(r.ID)) } // For-You / Discover / the discovery mixes are sized to ~100 // (#352/#411); songs_like_artist stays at systemMixLength (25). if r.TrackCount > 100 { t.Errorf("row %s: track_count=%d exceeds 100", uuidString(r.ID), r.TrackCount) } } if !hasForYou { t.Error("expected a for_you playlist") } if !hasSongsLike { t.Error("expected at least one songs_like_artist playlist") } } func TestBuildSystemPlaylists_StaleActivity_SongsLikeSurvives(t *testing.T) { // #1255: the seed-artist query's old hard 7-day window emptied the // pool after a quiet week, and the atomic-replace build then deleted // every "Songs like X" mix. With the tiered fallback, plays that are // ~20 days old (outside 7d, inside 30d) must still seed the mixes — // and the built tracks carry the tier2 stamp so metrics can compare // stale-seeded mixes against fresh ones. pool := newPool(t) logger := discardLogger() u := seedUser(t, pool, "stale1") old := time.Now().UTC().Add(-20 * 24 * time.Hour) for a := 0; a < 4; a++ { for k := 0; k < 3; k++ { tk := seedTrack(t, pool, "stale1-track-"+string(rune('A'+a))+string(rune('0'+k)), "stale1-artist-"+string(rune('A'+a))) for p := 0; p < 3; p++ { seedPlayEvent(t, pool, u.ID, tk.ID, old.Add(-time.Duration(a*10+k+p)*time.Hour), false) } } } if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil { t.Fatalf("build: %v", err) } rows, err := pool.Query(context.Background(), ` SELECT DISTINCT COALESCE(pt.pick_kind, '') FROM playlist_tracks pt JOIN playlists p ON p.id = pt.playlist_id WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist' `, u.ID) if err != nil { t.Fatalf("query pick_kinds: %v", err) } defer rows.Close() var kinds []string for rows.Next() { var k string if err := rows.Scan(&k); err != nil { t.Fatalf("scan: %v", err) } kinds = append(kinds, k) } if len(kinds) == 0 { t.Fatal("expected songs_like_artist tracks from the 30-day fallback tier; got none (the vanish bug)") } if len(kinds) != 1 || kinds[0] != "tier2" { t.Errorf("pick_kinds = %v, want exactly [tier2] (30-day fallback seeds)", kinds) } } func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) { pool := newPool(t) logger := discardLogger() u, tracks := seedActiveLibrary(t, pool, "qact", 3, 3) quarantined := tracks[0] seedQuarantine(t, pool, u.ID, quarantined.ID) if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil { t.Fatalf("build: %v", err) } // Inspect playlist_tracks for the user's system playlists; quarantined // track must not appear. rows, err := pool.Query(context.Background(), ` SELECT pt.track_id FROM playlist_tracks pt JOIN playlists p ON p.id = pt.playlist_id WHERE p.user_id = $1 AND p.kind = 'system' `, u.ID) if err != nil { t.Fatalf("query playlist_tracks: %v", err) } defer rows.Close() for rows.Next() { var tid pgtype.UUID if err := rows.Scan(&tid); err != nil { t.Fatalf("scan: %v", err) } if tid.Bytes == quarantined.ID.Bytes { t.Fatalf("quarantined track %s appeared in a system playlist", uuidString(quarantined.ID)) } } } func TestBuildSystemPlaylists_AtomicReplace(t *testing.T) { pool := newPool(t) logger := discardLogger() u, _ := seedActiveLibrary(t, pool, "atomic", 3, 3) ctx := context.Background() // First build. if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil { t.Fatalf("first build: %v", err) } var ids1 []pgtype.UUID rows1, _ := dbq.New(pool).ListPlaylistsByUserAndKind(ctx, dbq.ListPlaylistsByUserAndKindParams{ UserID: u.ID, Column2: "system", }) for _, r := range rows1 { ids1 = append(ids1, r.ID) } if len(ids1) == 0 { t.Skip("no system playlists from first build; can't verify replace") } // Second build — old rows must be gone, replaced with fresh ones. if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil { t.Fatalf("second build: %v", err) } rows2, _ := dbq.New(pool).ListPlaylistsByUserAndKind(ctx, dbq.ListPlaylistsByUserAndKindParams{ UserID: u.ID, Column2: "system", }) for _, r := range rows2 { for _, oldID := range ids1 { if r.ID.Bytes == oldID.Bytes { t.Errorf("playlist id %s persisted across rebuilds — atomic replace failed", uuidString(oldID)) } } } } func TestBuildSystemPlaylists_Concurrency(t *testing.T) { pool := newPool(t) logger := discardLogger() u, _ := seedActiveLibrary(t, pool, "conc", 3, 3) ctx := context.Background() now := time.Now().UTC() var wg sync.WaitGroup wg.Add(2) dataDir := t.TempDir() go func() { defer wg.Done(); _ = playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, now, dataDir) }() go func() { defer wg.Done(); _ = playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, now, dataDir) }() wg.Wait() // Exactly one run must have completed; in_flight must be false either way. run, err := dbq.New(pool).GetSystemPlaylistRun(ctx, u.ID) if err != nil { t.Fatalf("get run: %v", err) } if run.InFlight { t.Errorf("in_flight should be false after both calls return") } if !run.LastRunAt.Valid { t.Errorf("last_run_at should be set after at least one successful build") } } func TestBuildSystemPlaylists_DailyNonceDeterminism(t *testing.T) { pool := newPool(t) logger := discardLogger() u, _ := seedActiveLibrary(t, pool, "nonce", 4, 5) ctx := context.Background() day1 := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) dataDir := t.TempDir() if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, day1, dataDir); err != nil { t.Fatalf("build day1 first: %v", err) } snap1 := snapshotSystemTracks(t, pool, u.ID) if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, day1, dataDir); err != nil { t.Fatalf("build day1 second: %v", err) } snap2 := snapshotSystemTracks(t, pool, u.ID) if !equalSnapshots(snap1, snap2) { t.Errorf("same-day rebuild produced different track lists") } } // snapshotSystemTracks returns a per-playlist-name → []track_id mapping // for the user's system playlists, in playlist_tracks position order. func snapshotSystemTracks(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) map[string][]string { t.Helper() rows, err := pool.Query(context.Background(), ` SELECT p.name, pt.track_id FROM playlists p JOIN playlist_tracks pt ON pt.playlist_id = p.id WHERE p.user_id = $1 AND p.kind = 'system' ORDER BY p.name, pt.position `, userID) if err != nil { t.Fatalf("snapshot query: %v", err) } defer rows.Close() out := map[string][]string{} for rows.Next() { var name string var tid pgtype.UUID if err := rows.Scan(&name, &tid); err != nil { t.Fatalf("snapshot scan: %v", err) } out[name] = append(out[name], uuidString(tid)) } return out } func equalSnapshots(a, b map[string][]string) bool { if len(a) != len(b) { return false } for k, va := range a { vb, ok := b[k] if !ok || len(va) != len(vb) { return false } for i := range va { if va[i] != vb[i] { return false } } } return true } func TestListActiveUsersForSystemPlaylists(t *testing.T) { pool := newPool(t) ctx := context.Background() activeU, tracks := seedActiveLibrary(t, pool, "active", 1, 1) inactiveU := seedUser(t, pool, "inactive") // inactive user has plays older than 7 days. seedPlayEvent(t, pool, inactiveU.ID, tracks[0].ID, time.Now().UTC().AddDate(0, 0, -30), false) ids, err := dbq.New(pool).ListActiveUsersForSystemPlaylists(ctx) if err != nil { t.Fatalf("list: %v", err) } var sawActive, sawInactive bool for _, id := range ids { if id.Bytes == activeU.ID.Bytes { sawActive = true } if id.Bytes == inactiveU.ID.Bytes { sawInactive = true } } if !sawActive { t.Error("active user not returned") } if sawInactive { t.Error("inactive user (plays > 7d old) was returned") } } func TestBuildSystemPlaylists_DiscoverColdStart(t *testing.T) { // User has no plays / likes → Discover's dormant + cross-user // buckets are empty (or minimal), all slots come from random unheard. pool := newPool(t) logger := discardLogger() u, _ := seedActiveLibrary(t, pool, "discover_cold", 4, 5) // seedActiveLibrary seeds 3 plays per track; delete them so dormant // bucket (< 10 plays per artist) and random bucket both operate on // a true cold-start user with no prior play history. if _, err := pool.Exec(context.Background(), "DELETE FROM play_events WHERE user_id = $1", u.ID); err != nil { t.Fatalf("clear plays: %v", err) } if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil { t.Fatalf("build: %v", err) } rows, err := dbq.New(pool).ListPlaylistsByUserAndKind(context.Background(), dbq.ListPlaylistsByUserAndKindParams{ UserID: u.ID, Column2: "system", }) if err != nil { t.Fatalf("list: %v", err) } var foundDiscover bool for _, r := range rows { if r.SystemVariant != nil && *r.SystemVariant == "discover" { foundDiscover = true if r.TrackCount == 0 { t.Errorf("Discover playlist has zero tracks on cold start (random bucket should fill)") } if r.TrackCount > 100 { t.Errorf("Discover track_count = %d, want <= 100", r.TrackCount) } } } if !foundDiscover { t.Errorf("expected a 'discover' system playlist; got variants: %v", systemVariantsOf(rows)) } } func systemVariantsOf(rows []dbq.ListPlaylistsByUserAndKindRow) []string { out := make([]string, 0, len(rows)) for _, r := range rows { if r.SystemVariant != nil { out = append(out, *r.SystemVariant) } } return out } func TestStartupRecoveryClearsStaleInFlight(t *testing.T) { pool := newPool(t) ctx := context.Background() u := seedUser(t, pool, "stale") // Seed a stale in_flight=true row. if _, err := pool.Exec(ctx, `INSERT INTO system_playlist_runs (user_id, in_flight) VALUES ($1, true)`, u.ID); err != nil { t.Fatalf("seed runs: %v", err) } if err := dbq.New(pool).ClearStaleSystemPlaylistInFlight(ctx); err != nil { t.Fatalf("clear: %v", err) } run, err := dbq.New(pool).GetSystemPlaylistRun(ctx, u.ID) if err != nil { t.Fatalf("get: %v", err) } if run.InFlight { t.Errorf("in_flight should be false after ClearStale; got true") } }