Files
minstrel/internal/playlists/system.go
T
bvandeusen 0d0a8f46b1
test-go / test (push) Failing after 14s
test-web / test (push) Successful in 34s
test-go / integration (push) Successful in 4m42s
feat(tuning): scoring weights → DB-backed admin tuning lab
The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.

- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
  8 weight columns), taste_tuning singleton (engagement half-life +
  completion-curve points), recommendation_tuning_audit (one row per
  change with a {field, old, new} diff — the trend view's markers,
  #1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
  clobbering tuned rows (coverart SettingsService pattern), validates
  patches (bounds, curve ordering), writes audit rows, and pushes
  daily_mix weights + taste config into package playlists. No-op
  patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
  under a RWMutex — no signature threading through the producers; the
  scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
  weight fields leave config.RecommendationConfig (YAML keeps only
  RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
  echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
  card, per-scope save (changed fields only) + reset, deviation dots
  against shipped defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:22:03 -04:00

1000 lines
36 KiB
Go

// 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"
"sync"
"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"
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)
// 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: <n candidates returns whatever is available in
// shuffled order.
func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n int) []pgtype.UUID {
if len(pool) == 0 {
return nil
}
shuffled := make([]pgtype.UUID, len(pool))
copy(shuffled, pool)
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
rng.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
})
if len(shuffled) < n {
n = len(shuffled)
}
return shuffled[:n]
}
// pickSeedArtistsForDay picks up to 3 seed artists for the Songs-like
// mixes; For-You uses pickDailySeeds directly with forYouSeedCount.
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
return pickDailySeeds(pool, userID, dateStr, 3)
}
// rankedCandidate is a (track_id, score) pair used during in-memory
// sorting before insert into playlist_tracks. T5 fills these from
// recommendation.Candidate scores. PickKind is the track's provenance
// within its mix — For-You's head/tail split (#1249) and Discover's
// buckets (#1270); empty persists as NULL (variant doesn't stamp yet).
type rankedCandidate struct {
TrackID pgtype.UUID
Score float64
PickKind string
}
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
// attribute skips to the population that sourced the track — For You's
// "taste engine missing" vs "freshness tax", Discover's three buckets
// (#1270). Values mirror the CHECK in migration 0039; the tier1-3
// values there land with the tiered-mix rebuilds.
const (
pickKindTaste = "taste"
pickKindFresh = "fresh"
pickKindDormant = "dormant"
pickKindCrossUser = "cross_user"
pickKindRandom = "random"
pickKindTier1 = "tier1"
pickKindTier2 = "tier2"
pickKindTier3 = "tier3"
)
// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
// onto the rule-#131 pick-kind ladder: fresh 7-day engagement pins the
// mix's exact desire (tier1), the 30-day window steps back a little
// (tier2), and all-time / liked-only seeds are the far fallback
// (tier3). Stamped mix-wide — seed staleness is a property of the
// whole build, and it's what the metrics breakdown attributes skips to.
func pickKindForSeedTier(tier int32) string {
switch tier {
case 0:
return pickKindTier1
case 1:
return pickKindTier2
default:
return pickKindTier3
}
}
// pickKindForMixTier maps a tiered mix query's 1-based tier column
// (numbered to match rule #131's ladder directly) onto the pick-kind
// vocabulary. Distinct from pickKindForSeedTier, whose 0-based tiers
// count fallback steps of the seed pool rather than eligibility rungs.
func pickKindForMixTier(tier int32) string {
switch tier {
case 1:
return pickKindTier1
case 2:
return pickKindTier2
default:
return pickKindTier3
}
}
const systemMixLength = 25
// systemMixWeights are the scoring weights used by the daily builds.
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
// RNG (see scoreAndSortCandidates) — same (user, day) produces same
// scores within a day, but near-tied candidates reshuffle across days
// so the playlist doesn't feel frozen.
//
// DB-tunable since #1250: the recsettings service pushes the current
// daily_mix profile via SetSystemMixWeights at boot and on every admin
// change (coverart Configure() pattern — no signature threading, live
// effect without restart). The literal here is only the pre-push
// value; shipped defaults live in recsettings.ShippedDailyMixWeights,
// which must stay in sync with it.
var (
systemTuningMu sync.RWMutex
systemMixWeights = recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
// Taste profile (#796 phase 2): the daily mixes are the primary
// taste-driven surface, so they lean on it. TasteMatchScore is in
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
// while passive avoidance (negative) gently demotes.
TasteWeight: 1.5,
}
systemTasteConfig = taste.DefaultConfig()
)
// SetSystemMixWeights installs the current daily_mix scoring weights.
// Called by the recsettings service at boot and on admin updates.
func SetSystemMixWeights(w recommendation.ScoringWeights) {
systemTuningMu.Lock()
defer systemTuningMu.Unlock()
systemMixWeights = w
}
// SetTasteConfig installs the taste-profile build configuration
// (half-life + engagement curve, #1250). Same push model as
// SetSystemMixWeights.
func SetTasteConfig(c taste.Config) {
systemTuningMu.Lock()
defer systemTuningMu.Unlock()
systemTasteConfig = c
}
func currentSystemMixWeights() recommendation.ScoringWeights {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
return systemMixWeights
}
func currentTasteConfig() taste.Config {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
return systemTasteConfig
}
// forYouHeadN is the number of top-scored tracks that anchor the For-You
// playlist; forYouTailN is the number sampled from positions 2*headN
// onward, daily-deterministic via tieBreakHash. The 50 + 50 split
// (~50% from the tail) gives the user a substantially different mix
// day-to-day while preserving a recognizable anchor of strong
// similarity matches.
//
// Sized to 100 to match Discover so the shuffle-on-play default
// (system playlists shuffle when launched from a tile) has a deep
// enough pool that re-plays within a day feel varied. Libraries with
// a thin For-You candidate pool degrade gracefully: pickHeadAndTail
// returns top-N-by-score when the capped pool can't support a full
// head/tail split, exactly as Discover returns fewer than 100 when
// its buckets are thin. Songs-like-X keeps simple pickTopN (the
// seed-artist context already frames the "you'll like this" promise).
const (
forYouHeadN = 50
forYouTailN = 50
)
// forYouSeedCount is how many of the user's top-5 tracks seed each
// day's build (#1269). A single rotating seed made the mix bipolar
// day-to-day — swinging whole neighborhoods as the rotation moved
// between dissimilar anchors; blending 3 spans neighborhoods within
// one day and smooths the swing across days.
const forYouSeedCount = 3
// headQuotas fixes each seed's share of the For-You head so one
// neighborhood can't monopolize it even when its candidates out-score
// the others (#1269): 50/30/20 for three seeds, 60/40 for two, all of
// it for one. Rounding remainder goes to the primary seed; a thin
// seed's unfilled quota spills best-first in pickQuotaHead.
func headQuotas(numSeeds, headN int) []int {
if numSeeds <= 1 {
return []int{headN}
}
fractions := []float64{0.5, 0.3, 0.2}
if numSeeds == 2 {
fractions = []float64{0.6, 0.4}
}
if numSeeds > 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)
})
weights := currentSystemMixWeights()
pairs := make([]scored, len(ordered))
for i, c := range ordered {
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, 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))))
weights := currentSystemMixWeights()
out := make([]rankedCandidate, len(capped))
for i, c := range capped {
out[i] = rankedCandidate{
TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, weights, 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))))
weights := currentSystemMixWeights()
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, weights, 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, weights, 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 "<nil>"
}
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])
}