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, 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 / 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). // // 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. const discoveryMixLen = 100 // Minimum viable mix sizes (issue #1246): below the floor the variant // is withheld entirely so Home renders its "listen more to unlock" // placeholder — a playlist with a couple of songs reads as a build // bug, not a mix. Album-coherent mixes (NewForYou / FirstListens) get // a lower floor because one legitimate new album (~5+ tracks) is a // useful mix on its own; the scattered mixes need more to feel real. const ( discoveryMixMinLen = 15 discoveryMixMinLenAlbum = 5 ) // 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 // minLen is the minimum viable mix size: a finished pool below it // is withheld (no playlist row) so the client renders the locked // placeholder instead of a mix that looks built-wrong (#1246). minLen int // 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) } tracks := finishMix(pool, spec.diversify) if len(tracks) < spec.minLen { logger.Info("system playlist: "+spec.variant+" below minimum viable size; withholding", "user_id", uuidStringPL(userID), "pool", len(tracks), "min", spec.minLen) return nil, nil } return emit(spec.name, spec.variant, tracks), nil } } // rotateForDay rotates the pool left by a daily-deterministic offset // so each day's downstream truncate-to-N surfaces a different slice // while contiguous-block ordering inside the slice is preserved. // // Rotation happens WITHIN each contiguous same-pick-kind block // (#1267): tiered mixes arrive tier-ordered, and a whole-pool // rotation would hoist tier-3 filler above tier-1's exact fits. // Untiered pools are a single block, which reduces to the original // whole-pool rotation (same seed, same first draw). 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)))) out := make([]discoverTrack, 0, n) for start := 0; start < n; { end := start + 1 for end < n && pool[end].PickKind == pool[start].PickKind { end++ } block := pool[start:end] if len(block) > 1 { offset := rng.Intn(len(block)) out = append(out, block[offset:]...) out = append(out, block[:offset]...) } else { out = append(out, block...) } start = end } return out } // 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) minLen: discoveryMixMinLen, 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 minLen: discoveryMixMinLen, 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: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes minLen: discoveryMixMinLenAlbum, 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, PickKind: pickKindForMixTier(r.Tier), } } return out, nil }, }, { name: "On this day", variant: "on_this_day", diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2) minLen: discoveryMixMinLen, 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: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up minLen: discoveryMixMinLenAlbum, 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, PickKind: pickKindForMixTier(r.Tier), } } return out, nil }, }, } // 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 { var pool []discoverTrack if diversify { 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 } tracks := make([]rankedCandidate, len(pool)) for i, t := range pool { tracks[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind} } 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 { if len(tracks) == 0 { return nil } return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}} }