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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user