fix(server): unify discovery-mix producers + daily-rotate the deterministic ones
test-go / test (push) Failing after 19s
test-go / integration (push) Has been cancelled

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.
This commit is contained in:
2026-06-03 13:18:17 -04:00
parent 3df5e5cb3c
commit 7473e98d91
2 changed files with 188 additions and 108 deletions
+17 -10
View File
@@ -278,16 +278,23 @@ func RefreshableSystemKind(key string) bool {
// here (plus its candidate query). Order is the materialize order;
// it has no functional effect (atomic replace + per-playlist
// collage are order-independent).
var systemPlaylistRegistry = []systemPlaylistKind{
{Key: "for_you", Singleton: true, Produce: produceForYou},
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
{Key: "discover", Singleton: true, Produce: produceDiscover},
{Key: "deep_cuts", Singleton: true, Produce: produceDeepCuts},
{Key: "rediscover", Singleton: true, Produce: produceRediscover},
{Key: "new_for_you", Singleton: true, Produce: produceNewForYou},
{Key: "on_this_day", Singleton: true, Produce: produceOnThisDay},
{Key: "first_listens", Singleton: true, Produce: produceFirstListens},
}
var systemPlaylistRegistry = func() []systemPlaylistKind {
out := []systemPlaylistKind{
{Key: "for_you", Singleton: true, Produce: produceForYou},
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
{Key: "discover", Singleton: true, Produce: produceDiscover},
}
// The five discovery mixes share one Produce closure factory keyed
// by a per-mix spec; spec list + factory live in system_mixes.go.
for _, spec := range discoveryMixSpecs {
out = append(out, systemPlaylistKind{
Key: spec.variant,
Singleton: true,
Produce: produceDiscoveryMix(spec),
})
}
return out
}()
// systemForYouSourceLimits is a deeper candidate pool than the radio
// default. On a self-hosted library without ListenBrainz similarity
+171 -98
View File
@@ -3,6 +3,7 @@ package playlists
import (
"context"
"log/slog"
"math/rand"
"time"
"github.com/jackc/pgx/v5/pgtype"
@@ -10,18 +11,181 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Discovery mixes (#419-423). Each is one candidate query + a thin
// producer registered in systemPlaylistRegistry. They follow the
// Discover model: a SQL query returns ordered (id, album_id,
// artist_id) rows; we optionally diversity-cap, truncate, and emit
// rankedCandidate (Score unused — SQL gave the ranking). All are
// singleton kinds, so the generic by-kind refresh/shuffle endpoints
// and per-tile refresh affordance work with zero client changes.
// 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
@@ -52,94 +216,3 @@ func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist {
}
return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}}
}
func produceDeepCuts(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
UserID: userID, Column2: dateStr,
})
if err != nil {
logger.Warn("system playlist: deep-cuts query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return emit("Deep Cuts", "deep_cuts", finishMix(dt, true)), nil
}
func produceRediscover(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, _ string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListRediscoverTracks(ctx, userID)
if err != nil {
logger.Warn("system playlist: rediscover query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return emit("Rediscover", "rediscover", finishMix(dt, true)), nil
}
func produceNewForYou(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, _ string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListNewForYouTracks(ctx, userID)
if err != nil {
logger.Warn("system playlist: new-for-you query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
// Album-coherent: no diversity cap so whole new albums survive.
return emit("New for you", "new_for_you", finishMix(dt, false)), nil
}
func produceOnThisDay(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
UserID: userID, Column2: dateStr,
})
if err != nil {
logger.Warn("system playlist: on-this-day query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
return emit("On this day", "on_this_day", finishMix(dt, true)), nil
}
func produceFirstListens(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, _ string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListFirstListensTracks(ctx, userID)
if err != nil {
logger.Warn("system playlist: first-listens query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return nil, nil
}
dt := make([]discoverTrack, len(rows))
for i, r := range rows {
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
}
// Album-coherent (tiered by liked/played artist in SQL): no cap.
return emit("First listens", "first_listens", finishMix(dt, false)), nil
}