From 6da6cb5c5afc5294a94f086bceaf598f6a4ac31a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 13:22:23 -0400 Subject: [PATCH] fix(server): daily-rotate all deterministic mixes + diversity top-up fallback Operator feedback on the prior unification commit (7473e98d): 1. NewForYou should daily-rotate alongside Rediscover and FirstListens. The 'newest album first regardless of day' intent was the wrong call - operator wants visible day-over-day movement on every deterministic mix surface. Spec flipped to dailyRotate: true. 2. Diversity caps (<=2 per album / <=3 per artist) on every mix, not just the historically-diverse ones. The 2-per-album limit has helped a lot on the operator's library; extending it to NewForYou and FirstListens (previously album-coherent / no cap) surfaces more distinct albums per day. Spec flipped to diversify: true on all five. 3. Fallback when diversity caps strip the pool below the 100-track target: finishMix now calls topUpFromRaw, which appends non-capped tracks from the raw SQL pool (preserving original ranked order + skipping duplicates) until the target is hit or the pool runs out. On rich libraries the cap yields >= 100 and top-up never runs; on thin / album-heavy libraries we ship a partly-diversified 100 instead of a strictly-diversified 40. Net effect: every deterministic mix now rotates day-over-day, every mix gets the same diversity treatment (with graceful degradation), and the producer surface stays a single factory over a spec list. --- internal/playlists/system_mixes.go | 88 ++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/internal/playlists/system_mixes.go b/internal/playlists/system_mixes.go index 19e05dcb..4bd862e4 100644 --- a/internal/playlists/system_mixes.go +++ b/internal/playlists/system_mixes.go @@ -13,8 +13,8 @@ import ( // 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. +// fetched ranked (id, album_id, artist_id) rows, diversified, +// truncated, and emitted a single playlist. // // Day-keying is a per-mix property captured by `dailyRotate`: // @@ -22,18 +22,19 @@ import ( // 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). +// - Rediscover / NewForYou / FirstListens — SQL accepts only $1 +// user_id and produces deterministic ordering. `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 / NewForYou which +// are album-coherent — rotation walks the album boundary cleanly +// rather than scrambling within an album). // -// - 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. +// Diversity is `true` for every mix: per-album <= 2 / per-artist <= 3. +// On thin libraries where the cap would chop the pool below 100, +// finishMix tops up from the uncapped raw pool so the mix still +// ships a full-length playlist — see topUpFromRaw. // discoveryMixLen caps each mix at the same depth as For-You / // Discover so shuffle-on-play has a varied pool within a day. @@ -139,7 +140,7 @@ var discoveryMixSpecs = []discoveryMixSpec{ }, { name: "New for you", variant: "new_for_you", - diversify: false, dailyRotate: false, // newest-first is the intent + diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) { rows, err := q.ListNewForYouTracks(ctx, uid) if err != nil { @@ -171,7 +172,7 @@ var discoveryMixSpecs = []discoveryMixSpec{ }, { name: "First listens", variant: "first_listens", - diversify: false, dailyRotate: true, // SQL has no date arg; album-coherent so rotate (not shuffle) + diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) { rows, err := q.ListFirstListensTracks(ctx, uid) if err != nil { @@ -186,17 +187,30 @@ var discoveryMixSpecs = []discoveryMixSpec{ }, } -// 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. +// finishMix applies diversity caps (per-album <= 2 / per-artist <= 3) +// when diversify is set, with a top-up fallback when caps strip the +// pool below discoveryMixLen: the capped result is filled out with +// non-capped tracks (preserving original SQL order) until the target +// is hit or the raw pool runs out. +// +// The fallback matters on small / album-heavy libraries — the cap +// can chop a 200-row pool down to 40, and we'd rather ship a partly- +// diversified 100 than a strictly-diversified 40. On rich libraries +// the cap yields >= 100 and the top-up path never runs. func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate { - pool := rows + var pool []discoverTrack if diversify { - pool = capByAlbumAndArtist(pool) - } - if len(pool) > discoveryMixLen { - pool = pool[:discoveryMixLen] + capped := capByAlbumAndArtist(rows) + if len(capped) >= discoveryMixLen { + pool = capped[:discoveryMixLen] + } else { + pool = topUpFromRaw(capped, rows, discoveryMixLen) + } + } else { + pool = rows + if len(pool) > discoveryMixLen { + pool = pool[:discoveryMixLen] + } } if len(pool) == 0 { return nil @@ -208,6 +222,32 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate { return tracks } +// topUpFromRaw appends non-capped tracks from raw (in their original +// order) onto capped, skipping any already present, until the result +// reaches target or raw is exhausted. Preserves SQL ranking semantics +// for the non-diverse fill so the topped-up tail still trends best- +// first within each album. +func topUpFromRaw(capped, raw []discoverTrack, target int) []discoverTrack { + if len(capped) >= target { + return capped[:target] + } + seen := make(map[pgtype.UUID]struct{}, len(capped)) + for _, t := range capped { + seen[t.ID] = struct{}{} + } + pool := capped + for _, t := range raw { + if _, in := seen[t.ID]; in { + continue + } + pool = append(pool, t) + if len(pool) >= target { + break + } + } + return pool +} + // 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 {