// Package playlists' system.go implements the system-generated mix // builder (M7 #352 slice 2). The cron loop and lazy fallback both call // BuildSystemPlaylists; the helpers in this file (pickSeedArtists, // pickRepresentativeTrack, tieBreakHash) are the pure parts. package playlists import ( "bytes" "context" "crypto/sha256" "encoding/binary" "errors" "fmt" "log/slog" "math" "math/rand" "sort" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) // seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape. // Defined locally so unit tests don't need a real DB. type seedArtistRow struct { ArtistID pgtype.UUID Score int64 } // pickSeedArtistsFromRows projects sqlc rows into the seed list. The // SQL already orders by score DESC + artist_id and LIMIT 3, so this is // just a column projection — but pulling it into a function keeps the // call-site readable and makes the post-fetch path testable without // a database. func pickSeedArtistsFromRows(rows []seedArtistRow) []pgtype.UUID { out := make([]pgtype.UUID, 0, len(rows)) for _, r := range rows { out = append(out, r.ArtistID) } return out } // tieBreakHash returns a deterministic 64-bit hash of (track_id, date_str). // Used to break score ties and to drive the For-You tail sample's // daily-deterministic ordering. Same inputs always produce the same // output; different inputs almost always differ. // // Switched from FNV-1a (May 2026) because two date strings differing // only in the last character (e.g. "2026-05-07" vs "2026-05-08") gave // hash values whose RELATIVE ORDERING across our small candidate pools // was identical — the dateStr-driven divergence concentrated in low // bits that lost out to high-bit ordering during sort. SHA-256 // truncated to 8 bytes has full avalanche, so any single-byte input // change roughly half-flips the output bits and reorders meaningfully. // The hash isn't security-load-bearing; we just want strong avalanche // for tiny input deltas. func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 { h := sha256.New() if trackID.Valid { _, _ = h.Write(trackID.Bytes[:]) } _, _ = h.Write([]byte(dateStr)) sum := h.Sum(nil) return binary.BigEndian.Uint64(sum[:8]) } // userIDHash returns a deterministic 64-bit hash of (user_id, dateStr). // Same family as tieBreakHash, just keyed on user_id instead of // track_id. Used to drive daily-deterministic seed picks and to seed // the per-build scoring RNG: same (user, day) → same hash; different // days → different hashes. func userIDHash(userID pgtype.UUID, dateStr string) uint64 { h := sha256.New() if userID.Valid { _, _ = h.Write(userID.Bytes[:]) } _, _ = h.Write([]byte(dateStr)) sum := h.Sum(nil) return binary.BigEndian.Uint64(sum[:8]) } // pickDailySeeds takes a candidate pool of UUIDs and returns up to n // of them, daily-deterministically shuffled. Each day gets a different // ordering / selection, but within-day stability is preserved (same // inputs always produce the same output). // // Uses Fisher-Yates over a copy of the input, seeded by userIDHash. // Degrades gracefully: len(fractions) { // Not reachable at forYouSeedCount = 3; even split keeps any // future seed-count change from silently starving seeds. fractions = make([]float64, numSeeds) for i := range fractions { fractions[i] = 1.0 / float64(numSeeds) } } quotas := make([]int, numSeeds) assigned := 0 for i := 0; i < numSeeds; i++ { quotas[i] = int(float64(headN) * fractions[i]) assigned += quotas[i] } quotas[0] += headN - assigned return quotas } // pickQuotaHead walks the score-sorted capped pool best-first, taking // candidates whose originating seed still has head quota; slots a thin // seed can't fill spill best-first regardless of seed in a second // pass. A nil seedOf (single-seed path, tests) attributes everything // to seed 0, which reduces to plain top-headN. func pickQuotaHead(capped []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds, headN int) []recommendation.Candidate { quotas := headQuotas(numSeeds, headN) taken := make([]int, len(quotas)) inHead := make(map[pgtype.UUID]bool, headN) head := make([]recommendation.Candidate, 0, headN) for _, c := range capped { if len(head) >= headN { break } si := seedOf[c.Track.ID] if si < 0 || si >= len(quotas) { si = 0 } if taken[si] >= quotas[si] { continue } taken[si]++ head = append(head, c) inHead[c.Track.ID] = true } for _, c := range capped { if len(head) >= headN { break } if inHead[c.Track.ID] { continue } head = append(head, c) inHead[c.Track.ID] = true } return head } // forYouTailHalfLifeRanks tunes the fresh tail's rank bias (#1269): a // candidate's sampling weight halves every 50 ranks, so the 101st-best // candidate is far likelier than the 401st-best instead of equal — // freshness keeps its "you'll probably enjoy this" half. const forYouTailHalfLifeRanks = 50.0 // tailSampleUniverse quantizes tieBreakHash into a uniform (0,1] draw // for the weighted sample; 1e6 buckets is plenty of resolution for // pools of a few hundred. const tailSampleUniverse uint64 = 1_000_000 // pickWeightedTail samples up to tailN candidates from the rank- // ordered tail pool with exponentially rank-decaying weights, using // deterministic Efraimidis-Spirakis keys (u^(1/w), u derived from // tieBreakHash): same (track, day) → same key, so the sample is stable // within a day and rotates across days. Replaces the uniform daily // draw where rank 380 had the same chance as rank 101. func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN int) []recommendation.Candidate { if len(tailPool) <= tailN { return tailPool } type keyed struct { c recommendation.Candidate key float64 } keys := make([]keyed, len(tailPool)) for i, c := range tailPool { u := (float64(tieBreakHash(c.Track.ID, dateStr)%tailSampleUniverse) + 1) / float64(tailSampleUniverse+1) w := math.Exp2(-float64(i) / forYouTailHalfLifeRanks) keys[i] = keyed{c: c, key: math.Pow(u, 1/w)} } sort.SliceStable(keys, func(i, j int) bool { return keys[i].key > keys[j].key }) out := make([]recommendation.Candidate, tailN) for i := 0; i < tailN; i++ { out[i] = keys[i].c } return out } // scoreAndSortCandidates scores every candidate with recommendation.Score // and returns a new slice sorted by score DESC (ties broken by // tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is // deterministic per (user, day) but rotates across days. Pure — no // truncation, no cap. func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time) []recommendation.Candidate { rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) type scored struct { c recommendation.Candidate score float64 } // Pin candidate order by track id before drawing jitter. The candidate // query (LoadRadioCandidatesV2) has ORDER BY random() arms and no // stable outer ordering, so DB row order varies call-to-call. Since // the i-th candidate gets the i-th seeded jitter draw, an unstable // input order would assign different jitter to the same track across // same-day rebuilds and reorder near-ties — breaking the daily // determinism this function promises. Sorting by id first makes the // jitter assignment a function of (track, day) alone. ordered := make([]recommendation.Candidate, len(cands)) copy(ordered, cands) sort.SliceStable(ordered, func(i, j int) bool { return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID) }) pairs := make([]scored, len(ordered)) for i, c := range ordered { pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)} } sort.SliceStable(pairs, func(i, j int) bool { if pairs[i].score != pairs[j].score { return pairs[i].score > pairs[j].score } return tieBreakHash(pairs[i].c.Track.ID, dateStr) < tieBreakHash(pairs[j].c.Track.ID, dateStr) }) out := make([]recommendation.Candidate, len(pairs)) for i, p := range pairs { out[i] = p.c } return out } // capCandidatesByAlbumAndArtist trims a candidate list so no single // album appears more than discoverMaxTracksPerAlbum times and no // single artist appears more than discoverMaxTracksPerArtist times. // Mirrors capByAlbumAndArtist (which operates on []discoverTrack); // here the input/output is []recommendation.Candidate so For-You and // Songs-like-X can apply the same diversity caps as Discover. // // Preserves input order. Reuses the discoverMax* constants — // playlists across all three system variants get consistent // diversity behavior. func capCandidatesByAlbumAndArtist(cands []recommendation.Candidate) []recommendation.Candidate { albumCount := map[pgtype.UUID]int{} artistCount := map[pgtype.UUID]int{} out := make([]recommendation.Candidate, 0, len(cands)) for _, c := range cands { if albumCount[c.Track.AlbumID] >= discoverMaxTracksPerAlbum { continue } if artistCount[c.Track.ArtistID] >= discoverMaxTracksPerArtist { continue } albumCount[c.Track.AlbumID]++ artistCount[c.Track.ArtistID]++ out = append(out, c) } return out } // builtPlaylist is what a producer hands back: one playlist to // materialize. A producer may return zero (no eligible tracks), // one (For-You / Discover / each new kind), or many (Songs-like-X, // one per seed artist) of these. type builtPlaylist struct { Name string Variant string SeedArtistID pgtype.UUID // zero value for non-seeded kinds Tracks []rankedCandidate } // systemPlaylistProducer computes the playlists for one kind for a // given user/day. A returned error is fatal to the whole build // (e.g. a required base query failed); per-item issues should be // logged and yield fewer playlists, not an error. type systemPlaylistProducer func( ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string, now time.Time, ) ([]builtPlaylist, error) type systemPlaylistKind struct { Key string // Singleton kinds produce exactly one playlist per user (For-You, // Discover, the discovery mixes) and so can be addressed by kind: // the generic /system/{kind}/{refresh,shuffle} endpoints + the // per-tile refresh affordance only apply to these. Non-singleton // kinds (songs_like_artist → up to 3 per user) are played by // playlist id and have no by-kind endpoint. Singleton bool Produce systemPlaylistProducer } // RefreshableSystemKind reports whether `key` is a known singleton // system-playlist kind — i.e. addressable by the generic by-kind // refresh/shuffle endpoints and eligible for the per-tile refresh // affordance. The API layer + clients use this so neither hardcodes // the kind list. func RefreshableSystemKind(key string) bool { for _, k := range systemPlaylistRegistry { if k.Key == key { return k.Singleton } } return false } // systemPlaylistRegistry is the single source of truth for which // system playlists exist. BuildSystemPlaylists iterates it; the // generic /api/playlists/system/{kind}/{refresh,shuffle} endpoints // validate against it. Adding a new mix = a producer + one entry // 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 = 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 // data the lb_similar / similar_artists sources contribute nothing, // so the default (~130 raw, ~40 after dedup + diversity caps) can // never fill For-You's 100-track head/tail. The raised random/tag // fill keeps For-You ~100 deep regardless of LB enrichment; when LB // data IS present the larger lb/similar K just makes it richer. func systemForYouSourceLimits() recommendation.CandidateSourceLimits { return recommendation.CandidateSourceLimits{ LBSimilar: 80, SimilarArtist: 80, TagOverlap: 60, LikesOverlap: 40, RandomFill: 150, // For-You / You-might-like are the taste-driven surfaces, so pull a // deep slice of the user's top taste-profile artists into the pool // (#796 phase 2b). Empty for cold-start users (no profile yet). TasteOverlap: 80, } } // produceForYou: blend candidate pools from up to forYouSeedCount of // the user's top-5 played tracks (rotating daily via pickDailySeeds), // then head+tail composition with per-seed head quotas (#1269). The // base seed query failing is fatal; a per-seed candidate-load failure // is logged and that seed just contributes nothing. func produceForYou( ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string, now time.Time, ) ([]builtPlaylist, error) { forYouSeeds, err := q.PickTopPlayedTracksForUser(ctx, userID) if err != nil { return nil, fmt.Errorf("pick for-you seed candidates: %w", err) } seeds := pickDailySeeds(forYouSeeds, userID, dateStr, forYouSeedCount) if len(seeds) == 0 { return nil, nil } zeroVec := recommendation.SessionVector{Seed: true} // Merge per-seed pools; first-seen wins on dedup, and seedOf // remembers which seed sourced each track for the head quotas. var merged []recommendation.Candidate seedOf := map[pgtype.UUID]int{} for i, seed := range seeds { cands, cerr := recommendation.LoadCandidatesFromSimilarity( ctx, q, userID, seed, 1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood zeroVec, seeds, systemForYouSourceLimits(), ) if cerr != nil { logger.Warn("system playlist: for-you candidates load failed for seed; continuing", "user_id", uuidStringPL(userID), "seed", uuidStringPL(seed), "err", cerr) continue } for _, c := range cands { if _, seen := seedOf[c.Track.ID]; seen { continue } seedOf[c.Track.ID] = i merged = append(merged, c) } } if len(merged) == 0 { return nil, nil } tracks := pickHeadAndTail(merged, seedOf, len(seeds), userID, dateStr, now, forYouHeadN, forYouTailN) if len(tracks) == 0 { return nil, nil } return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil } // produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed // artists rotate daily-deterministically; the seed query falls back // through widening engagement windows (#1255) and every returned row // shares the winning tier, stamped onto the built tracks as their // pick_kind. The base seed-artist query failing is fatal; per-artist // failures are logged + skipped. func produceSeedMixes( ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string, now time.Time, ) ([]builtPlaylist, error) { seedRows, err := q.PickSeedArtists(ctx, userID) if err != nil { return nil, fmt.Errorf("pick seed artists: %w", err) } seedTierKind := "" if len(seedRows) > 0 { seedTierKind = pickKindForSeedTier(seedRows[0].Tier) } seedRowsLocal := make([]seedArtistRow, 0, len(seedRows)) for _, r := range seedRows { seedRowsLocal = append(seedRowsLocal, seedArtistRow{ ArtistID: r.ArtistID, Score: r.Score, }) } seedPool := pickSeedArtistsFromRows(seedRowsLocal) seeds := pickSeedArtistsForDay(seedPool, userID, dateStr) out := make([]builtPlaylist, 0, len(seeds)) for _, artistID := range seeds { artistRow, aerr := q.GetArtistByID(ctx, artistID) if aerr != nil { logger.Warn("system playlist: seed artist load failed; skipping", "artist_id", uuidStringPL(artistID), "err", aerr) continue } seedTrack, terr := q.PickTopPlayedTrackForArtistByUser(ctx, dbq.PickTopPlayedTrackForArtistByUserParams{ UserID: userID, ArtistID: artistID, }) if terr != nil || !seedTrack.Valid { continue } zeroVec := recommendation.SessionVector{Seed: true} cands, cerr := recommendation.LoadCandidatesFromSimilarity( ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack}, recommendation.DefaultCandidateSourceLimits(), ) if cerr != nil { logger.Warn("system playlist: seed candidates load failed; skipping", "artist_id", uuidStringPL(artistID), "err", cerr) continue } // "Songs like X" excludes X's own songs. filtered := make([]recommendation.Candidate, 0, len(cands)) for _, c := range cands { if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) { filtered = append(filtered, c) } } tracks := pickTopN(filtered, userID, dateStr, now, systemMixLength) if len(tracks) == 0 { continue } for i := range tracks { tracks[i].PickKind = seedTierKind } out = append(out, builtPlaylist{ Name: fmt.Sprintf("Songs like %s", artistRow.Name), Variant: "songs_like_artist", SeedArtistID: artistID, Tracks: tracks, }) } return out, nil } // produceDiscover: unheard tracks biased toward dormant artists. // Bucket failures are handled inside buildDiscoverCandidates and // redistribute as deficit; a returned error is non-fatal here // (logged, yields no Discover) to match the prior behavior. func produceDiscover( ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string, _ time.Time, ) ([]builtPlaylist, error) { tracks, derr := buildDiscoverCandidates(ctx, q, logger, userID, dateStr) if derr != nil { logger.Warn("system playlist: discover candidates load failed; skipping", "user_id", uuidStringPL(userID), "err", derr) return nil, nil } if len(tracks) == 0 { return nil, nil } return []builtPlaylist{{Name: "Discover", Variant: "discover", Tracks: tracks}}, nil } // BuildSystemPlaylists builds the user's daily system mixes (one For-You + // up to 3 Songs-like-{seed} mixes). Atomic-replace inside one tx; // concurrency-guarded via system_playlist_runs.in_flight; deterministic // within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")). // // Callable from both the cron loop (system_cron.go) and the lazy fallback. // Returns nil when the user has no library activity to build from. Errors // are captured in system_playlist_runs.last_error before returning. func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, userID pgtype.UUID, now time.Time, dataDir string) error { q := dbq.New(pool) dateStr := now.UTC().Format("2006-01-02") dateOnly := pgtype.Date{Time: now.UTC(), Valid: true} // Concurrency guard: try to claim the run row. if _, err := q.TryClaimSystemPlaylistRun(ctx, userID); err != nil { if errors.Is(err, pgx.ErrNoRows) { logger.Debug("system playlist build: another run is in flight", "user_id", uuidStringPL(userID)) return nil } return fmt.Errorf("claim run: %w", err) } // On any return path, mark the run finished. Failure capture is // best-effort; success is updated in the happy path. var buildErr error defer func() { if buildErr == nil { if err := q.FinishSystemPlaylistRun(ctx, dbq.FinishSystemPlaylistRunParams{ UserID: userID, LastRunAt: pgtype.Timestamptz{Time: now.UTC(), Valid: true}, LastRunDate: dateOnly, }); err != nil { logger.Warn("system playlist: failed to mark run finished; in_flight may be stuck", "user_id", uuidStringPL(userID), "err", err) } } else { errStr := buildErr.Error() if err := q.FailSystemPlaylistRun(ctx, dbq.FailSystemPlaylistRunParams{ UserID: userID, LastError: &errStr, }); err != nil { logger.Warn("system playlist: failed to record build failure", "user_id", uuidStringPL(userID), "err", err) } } }() // Run every registered system-playlist producer. Each returns the // playlists it wants materialized for this user/day (zero or more); // a returned error is fatal to the whole build (matches the prior // behavior where a seed-query failure aborted). Per-item issues are // logged inside the producer and just yield fewer playlists. var built []builtPlaylist for _, kind := range systemPlaylistRegistry { out, perr := kind.Produce(ctx, q, logger, userID, dateStr, now) if perr != nil { buildErr = fmt.Errorf("produce %s: %w", kind.Key, perr) return buildErr } built = append(built, out...) } // "You might like" Home rows reuse the same similarity engine but // persist to dedicated album/artist tables (not playlist_tracks). // Computed here (reads only) and atomic-replaced inside the tx below, // alongside the system playlists. A failed/gated computation is // handled by yml.built (leave prior rows vs. clear) — never fatal. yml := buildYouMightLike(ctx, q, logger, userID, dateStr, now) // Atomic replace inside a transaction. tx, err := pool.Begin(ctx) if err != nil { buildErr = fmt.Errorf("begin tx: %w", err) return buildErr } defer func() { _ = tx.Rollback(ctx) }() qtx := dbq.New(tx) if err := qtx.DeleteSystemPlaylistsForUser(ctx, userID); err != nil { buildErr = fmt.Errorf("delete old system playlists: %w", err) return buildErr } // Track the playlist IDs we create so we can generate collage covers // for each one after the transaction commits. Collage generation // reads the playlist's tracks via a separate query that won't see // uncommitted rows, so the work happens post-commit. createdIDs := make([]pgtype.UUID, 0, len(built)) for _, bp := range built { if len(bp.Tracks) == 0 { continue } id, err := insertSystemPlaylist(ctx, qtx, userID, bp.Name, bp.Variant, bp.SeedArtistID, bp.Tracks) if err != nil { buildErr = fmt.Errorf("insert %s (%s): %w", bp.Variant, bp.Name, err) return buildErr } createdIDs = append(createdIDs, id) } if yml.built { if err := persistYouMightLike(ctx, qtx, userID, yml); err != nil { buildErr = fmt.Errorf("persist you-might-like: %w", err) return buildErr } } if err := tx.Commit(ctx); err != nil { buildErr = fmt.Errorf("commit tx: %w", err) return buildErr } // Post-commit: generate a 4-cell collage cover for each system // playlist from the contributing tracks' album covers. The collage // gracefully falls back to the FabledSword glyph in cells whose // album lacks cover art, so a partially-covered library still // produces a sensible visual rather than an empty placeholder. // Errors are non-fatal — the playlist is already committed; a // missing collage just leaves cover_path NULL until next build. if dataDir != "" { for _, plID := range createdIDs { if _, cerr := GenerateCollage(ctx, pool, plID, dataDir); cerr != nil { logger.Warn("system playlist: collage generation failed", "playlist_id", uuidStringPL(plID), "err", cerr) } } } return nil } // pickTopN sorts candidates by score DESC, applies per-album (<=2) / // per-artist (<=3) diversity caps matching Discover's behavior, then // truncates to n. Used by Songs-like-X (and as the fallback inside // pickHeadAndTail for small pools). func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate { sorted := scoreAndSortCandidates(cands, userID, dateStr, now) capped := capCandidatesByAlbumAndArtist(sorted) if len(capped) > n { capped = capped[:n] } rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) out := make([]rankedCandidate, len(capped)) for i, c := range capped { out[i] = rankedCandidate{ TrackID: c.Track.ID, Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64), } } return out } // pickHeadAndTail picks headN taste anchors from the score-sorted pool // (under per-seed quotas when seedOf/numSeeds describe a multi-seed // blend, #1269) plus tailN freshness picks sampled rank-weighted from // positions 2*headN onward. Caps applied before the head/tail split. // Used by For-You only. // // The "tail" — candidates ranked beyond 2*headN — is still similarity- // related (every candidate passed the similarity filter) but isn't among // the obvious top hits. Sampling from there gives the user variety they'll // probably enjoy without resorting to genuinely random unrelated tracks. // // Falls back to standard pickTopN behavior when the candidate pool is too // small to support a meaningful head/tail split (capped pool <= // headN+tailN, or no candidates at or beyond position 2*headN). func pickHeadAndTail( cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int, ) []rankedCandidate { sorted := scoreAndSortCandidates(cands, userID, dateStr, now) capped := capCandidatesByAlbumAndArtist(sorted) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) total := headN + tailN if len(capped) <= total { // Pool too small for a head/tail split — return up to total entries. // All marked taste: top-N-by-score IS the taste mechanism; no // exploration sampling happens on this path. if len(capped) < total { total = len(capped) } out := make([]rankedCandidate, total) for i := 0; i < total; i++ { out[i] = rankedCandidate{ TrackID: capped[i].Track.ID, Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64), PickKind: pickKindTaste, } } return out } head := pickQuotaHead(capped, seedOf, numSeeds, headN) inHead := make(map[pgtype.UUID]bool, len(head)) for _, c := range head { inHead[c.Track.ID] = true } tailStart := 2 * headN if tailStart >= len(capped) { tailStart = headN } // The tail pool keeps its rank order (position drives the sampling // weight); head members are excluded — a quota walk can reach past // tailStart when a seed's candidates rank deep. tailPool := make([]recommendation.Candidate, 0, len(capped)-tailStart) for _, c := range capped[tailStart:] { if inHead[c.Track.ID] { continue } tailPool = append(tailPool, c) } tail := pickWeightedTail(tailPool, dateStr, tailN) // Combine: head first (score-sorted under quotas), then the fresh // sample. "First similar, then surprise" reads naturally in // playback. Head entries are the taste picks; tail entries are the // freshness injection (#1249) — the split the metrics page // attributes skips to. combined := make([]recommendation.Candidate, 0, len(head)+len(tail)) combined = append(combined, head...) combined = append(combined, tail...) out := make([]rankedCandidate, len(combined)) for i, c := range combined { kind := pickKindTaste if i >= len(head) { kind = pickKindFresh } out[i] = rankedCandidate{ TrackID: c.Track.ID, Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64), PickKind: kind, } } return out } // insertSystemPlaylist inserts a kind='system' playlists row plus its // playlist_tracks via AppendPlaylistTrack (slice 1's existing query, which // does the snapshot lookup from tracks/albums/artists). Then recomputes // rollups. Returns the new playlist's ID so the caller can drive // post-commit collage generation. // // Pass seed_artist_id with Valid=false (zero pgtype.UUID) for the For-You // variant; the CreateSystemPlaylist query persists NULL. // // cover_path is always inserted as NULL — the post-commit collage step // in BuildSystemPlaylists overrides it with a 4-cell composite of the // contributing tracks' album art. func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID, name, variant string, seedArtistID pgtype.UUID, tracks []rankedCandidate) (pgtype.UUID, error) { variantStr := variant p, err := qtx.CreateSystemPlaylist(ctx, dbq.CreateSystemPlaylistParams{ UserID: userID, Name: name, SystemVariant: &variantStr, SeedArtistID: seedArtistID, CoverPath: nil, }) if err != nil { return pgtype.UUID{}, fmt.Errorf("insert playlist row: %w", err) } for _, t := range tracks { var pickKind *string if t.PickKind != "" { k := t.PickKind pickKind = &k } if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{ PlaylistID: p.ID, TrackID: t.TrackID, PickKind: pickKind, }); err != nil { // Track may have been deleted between candidate-load and insert; // skip silently rather than failing the whole build. if errors.Is(err, pgx.ErrNoRows) { continue } return pgtype.UUID{}, fmt.Errorf("append track %s: %w", uuidStringPL(t.TrackID), err) } } if err := qtx.UpdatePlaylistRollups(ctx, p.ID); err != nil { return pgtype.UUID{}, fmt.Errorf("update rollups: %w", err) } return p.ID, nil } // uuidStringPL renders a pgtype.UUID as the canonical 8-4-4-4-12 form. // (pgtype.UUID's String method exists on some pgx versions but not others; // also the playlists package needs this independent of test helpers.) // Suffix `PL` distinguishes it from any test helper named uuidString. // uuidLessPL reports whether a sorts before b by raw 16-byte value. Used // to pin candidate order deterministically before jitter assignment. func uuidLessPL(a, b pgtype.UUID) bool { return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0 } func uuidStringPL(u pgtype.UUID) string { if !u.Valid { return "" } return fmt.Sprintf("%x-%x-%x-%x-%x", u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16]) }