Files
minstrel/internal/playlists/system_mixes.go
T
bvandeusen 7473e98d91
test-go / test (push) Failing after 19s
test-go / integration (push) Has been cancelled
fix(server): unify discovery-mix producers + daily-rotate the deterministic ones
The five discovery-mix producers (Deep Cuts, Rediscover, New for you,
On this day, First listens) were near-identical boilerplate that
differed only in (a) which SQL query they ran and (b) whether to
diversity-cap the result. Folded into one produceDiscoveryMix(spec)
factory + a per-mix discoveryMixSpec slice. The registry composes the
factory over the spec list so adding a new mix is one struct literal
+ a SQL query, never a new func.

Also fixes the user-reported bug that several mixes 'show the same
content from yesterday'. Audit of the SQL queries:

  - Deep Cuts:   ORDER BY md5(t.id::text || $2::text)   → day-keyed
  - On this day: ORDER BY w.c DESC, md5(...)              → day-keyed
  - Rediscover:  ORDER BY tier, c DESC, id                → invariant
  - New for you: ORDER BY al.created_at DESC, disc, track → invariant
  - First listens: ORDER BY tier, al.id, disc, track      → invariant

The three invariant ones produced identical content day-over-day. The
unified spec carries a dailyRotate bool: when set, the producer
applies a daily-deterministic offset rotate-left of the candidate
pool BEFORE diversify+truncate. Rotation (not shuffle) preserves
contiguous-block ordering inside each day's slice — matters for First
listens which is album-coherent.

Set on Rediscover + First listens (where same-content-every-day is
clearly a bug). Left off New for you because 'newest album first
regardless of day' is the intended UX for that surface — daily
rotation there would feel wrong.

Daily rotation seed: rand.New(NewSource(int64(userIDHash(userID,
dateStr)))) — same primitive used by For-You's pickHeadAndTail
sampling so behavior is consistent across the system playlist family.

No test file referenced the deleted produceXxx functions directly,
only the registry, so this is a closed refactor.
2026-06-03 13:18:17 -04:00

219 lines
7.7 KiB
Go

package playlists
import (
"context"
"log/slog"
"math/rand"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Discovery mixes (#419-423). One generic producer + per-mix spec.
// Replaces the five near-identical produceXxx functions that all
// fetched ranked (id, album_id, artist_id) rows, optionally
// diversified, truncated, and emitted a single playlist.
//
// Day-keying is a per-mix property captured by `dailyRotate`:
//
// - DeepCuts / OnThisDay — SQL already day-keys via
// ORDER BY md5(t.id::text || $2::text), so the Go producer keeps
// SQL order. `dailyRotate: false`.
//
// - Rediscover / FirstListens — SQL accepts only $1 user_id and
// produces deterministic ordering. Same content day-over-day
// until library state shifts. `dailyRotate: true` applies a
// daily-deterministic rotate-left of the pool BEFORE diversify+
// truncate so each day's top-100 surfaces a different slice
// while contiguous-block ordering within each slice is preserved
// (matters for FirstListens which is album-coherent).
//
// - NewForYou — SQL produces a newest-album-first ordering whose
// intent is "see what's new". Day-over-day same content is
// correct UX: the user's "what's new" list shouldn't rotate. The
// spec carries `dailyRotate: false` deliberately.
// discoveryMixLen caps each mix at the same depth as For-You /
// Discover so shuffle-on-play has a varied pool within a day.
const discoveryMixLen = 100
// discoveryMixSpec describes one discovery mix. The unified producer
// reads the spec and runs a single code path for all variants.
type discoveryMixSpec struct {
name string
variant string
diversify bool
// dailyRotate, when true, applies a daily-deterministic offset
// rotation to the candidate pool BEFORE diversify+truncate so the
// top discoveryMixLen rotates day-over-day. Set on variants whose
// SQL ORDER is invariant to dateStr (Rediscover, FirstListens).
// Leave false when the SQL already day-keys (DeepCuts, OnThisDay)
// or when day-over-day stability is the intended UX (NewForYou).
dailyRotate bool
// fetch returns the raw ranked rows. dateStr is supplied for
// queries that accept it (passed as the second positional arg
// historically); queries that don't accept it ignore the param.
fetch func(context.Context, *dbq.Queries, pgtype.UUID, string) ([]discoverTrack, error)
}
// produceDiscoveryMix returns a systemPlaylistKind.Produce closure
// bound to the given spec. Registered in systemPlaylistRegistry; see
// the discoveryMixSpecs slice below for the concrete instances.
func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
return func(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := spec.fetch(ctx, q, userID, dateStr)
if err != nil {
logger.Warn("system playlist: "+spec.variant+" query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
pool := rows
if spec.dailyRotate {
pool = rotateForDay(pool, userID, dateStr)
}
return emit(spec.name, spec.variant, finishMix(pool, spec.diversify)), nil
}
}
// 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.
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
}
// discoveryMixSpecs is the concrete spec list used by the registry in
// system.go. Adding a new mix = one entry here + the candidate query.
//
// Order has no functional effect (insert-time atomic replace is order
// independent); listed in the same order as the historical registry.
var discoveryMixSpecs = []discoveryMixSpec{
{
name: "Deep Cuts", variant: "deep_cuts",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
UserID: uid, Column2: ds,
})
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
{
name: "Rediscover", variant: "rediscover",
diversify: true, dailyRotate: true, // SQL has no date arg
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListRediscoverTracks(ctx, uid)
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
{
name: "New for you", variant: "new_for_you",
diversify: false, dailyRotate: false, // newest-first is the intent
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListNewForYouTracks(ctx, uid)
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
{
name: "On this day", variant: "on_this_day",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
UserID: uid, Column2: ds,
})
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
{
name: "First listens", variant: "first_listens",
diversify: false, dailyRotate: true, // SQL has no date arg; album-coherent so rotate (not shuffle)
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListFirstListensTracks(ctx, uid)
if err != nil {
return nil, err
}
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return out, nil
},
},
}
// finishMix caps (per-album<=2 / per-artist<=3) when diversify is
// set, truncates to discoveryMixLen, and converts to the insert
// type. Album-coherent mixes (New for you, First Listens) pass
// diversify=false so whole albums survive.
func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
pool := rows
if diversify {
pool = capByAlbumAndArtist(pool)
}
if len(pool) > discoveryMixLen {
pool = pool[:discoveryMixLen]
}
if len(pool) == 0 {
return nil
}
tracks := make([]rankedCandidate, len(pool))
for i, t := range pool {
tracks[i] = rankedCandidate{TrackID: t.ID}
}
return tracks
}
// emit wraps the finished track list in a single builtPlaylist (the
// discovery mixes are all singletons). nil tracks → no playlist.
func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist {
if len(tracks) == 0 {
return nil
}
return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}}
}