Files
minstrel/internal/playlists/system.go
T
bvandeusenandClaude Opus 5 721154847e
test-go / test (push) Successful in 1m5s
test-go / integration (push) Failing after 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 1m38s
fix(recommendation): size the candidate pool to the library
Operator, 2026-09-10: "is the pool that we draw from somehow scaled to the
amount of music in the library... my earlier understanding of the tuning and
work may have been skewed by what was in my library."

It was not. DefaultCandidateSourceLimits returns what its own comment calls
"the v1 hardcoded constants per spec" — ~170 candidates for a 500-track
library and a 100,000-track one alike. The pool therefore samples a
shrinking FRACTION of a growing collection: 17% of 1,000 tracks, 1.7% of
10,000, 0.17% of 100,000. RandomFill, whose whole job is exploration,
becomes a thinner and noisier slice at exactly the moment a library gets
more diverse — which is the "starting to feel weird" being reported.

    1,000 tracks -> pool 170     (unchanged)
    5,000        -> pool 170     (unchanged)
   20,000        -> pool 280
   80,000        -> pool 500     (ceiling)

THE SCALING IS PER-ARM, and that is the substance rather than a refinement.
A limit only matters if there are rows for it to cut off, so what an arm is
BOUNDED BY decides whether library size can help it. LBSimilar,
SimilarArtist, TagOverlap and RandomFill grow: they are bounded by
similarity/tag data and by the library itself. LikesOverlap, UserCoplay and
TasteOverlap do not: they are bounded by the user's likes, the instance's
co-play graph and the taste profile, none of which grow when the library
does. Raising those would sample more of a set that did not change — churn,
not reach. It also keeps this from inflating the sim_score-0 share, since
TasteOverlap is one of the two zero-similarity arms.

sqrt, not linear: linear would put a 100,000-track library at a
3,400-candidate pool, long past where more candidates improve the answer.
A 4x ceiling bounds it at ~500.

Never shrinks an arm. The base limits are a floor, and #3889 makes that
load-bearing rather than tidy — shrinking an arm ordered by unseeded
random() changes pool membership between same-day rebuilds.

Library size comes from a TTL-cached count reusing CountTracksMatching with
an empty pattern (rule 28 — a new query would need sqlc regeneration, which
is blocked). The ILIKE defeats every index, so it is a full scan and must
not run per request. It degrades rather than fails: an error keeps the last
known value, a never-counted cache returns 0, and 0 scales to the base
limits — today's behaviour exactly. Nothing about sizing a pool justifies
failing the request it is sizing. Bounded by a 3s deadline (rule 156), and
a failed refresh does not stamp the clock, so a blip cannot pin a stale
value for the whole TTL.

THE REFERENCE IS ASSUMED, NOT MEASURED. libraryScaleReference = 5000 is
where growth starts, and the size the v1 constants were really tuned against
is unrecorded. #3879 should replace it; until then that constant is the one
thing to change. Deliberately conservative: below it nothing scales at all,
so no existing install changes behaviour.

Falsification caught a weak guard: the sqrt-vs-linear assertion was written
at SIXTEEN times the reference, where linear has already been clamped by the
ceiling and both curves land on 4x. It proved nothing. Moved to four times
the reference, below the ceiling for both, where sqrt gives 2x and linear
would give 4x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 23:23:04 -04:00

1103 lines
41 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 12, 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]
}
// songsLikeSeedCount is how many "Songs like {artist}" mixes to build
// each day (#1491). Bumped from 3 when Songs-like was promoted to its
// own dedicated Home row — the best-performing surface (low skip / high
// completion) was buried as 3 tiles in the shared Playlists carousel, so
// the wider row now shows a wider spread. Drawn from PickSeedArtists'
// deeper top-12 pool via a daily-deterministic shuffle so the set still
// rotates day to day rather than pinning the same six artists.
const songsLikeSeedCount = 6
// pickSeedArtistsForDay picks up to songsLikeSeedCount 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, songsLikeSeedCount)
}
// 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 0041; taste_unheard is
// Discover's taste-targeted-novelty arm (#1488).
const (
pickKindTaste = "taste"
pickKindFresh = "fresh"
pickKindDormant = "dormant"
pickKindTasteUnheard = "taste_unheard"
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,
// Time-of-day/weekday context affinity (#1531), in [-1,+1]. Starts
// uniform with radio pending trend data.
ContextTimeWeight: 1.0,
}
// Songs-like's own profile (#3881). Pre-push literal only; shipped
// defaults live in recsettings.ShippedSongsLikeWeights and must stay in
// sync with it, exactly as systemMixWeights does above.
//
// SimilarityWeight dominates here and every seed-INDEPENDENT term is
// demoted, which is the whole difference between this surface and For-You.
// See ShippedSongsLikeWeights for the property the numbers encode.
songsLikeWeights = recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 0.5,
RecencyWeight: 0.25,
SkipPenalty: 2.0,
JitterMagnitude: 0.05,
ContextWeight: 0.5,
SimilarityWeight: 4.0,
TasteWeight: 0.25,
ContextTimeWeight: 0.5,
}
systemTasteConfig = taste.DefaultConfig()
// Sizes the candidate pool to the library (#3880). Cached because the
// count is a full table scan and the daily build runs it once per user;
// within the TTL every user in a build shares one count.
systemLibrarySize = recommendation.NewLibrarySize(nil)
)
// 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
}
// SetSongsLikeWeights installs the songs_like scoring profile (#3881).
// Same push model as SetSystemMixWeights — recsettings calls it on boot and
// after every knob turn, so a tuning change takes effect on the next daily
// build with no restart.
//
// Separate from systemMixWeights because Songs-like and For-You want opposite
// things: For-You roams, Songs-like must not. Sharing one profile is what made
// "Songs like X" wander.
func SetSongsLikeWeights(w recommendation.ScoringWeights) {
systemTuningMu.Lock()
defer systemTuningMu.Unlock()
songsLikeWeights = 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 currentSongsLikeWeights() recommendation.ScoringWeights {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
return songsLikeWeights
}
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.
// weights is a parameter rather than a read of currentSystemMixWeights()
// because this sort IS the selection: the caller caps and truncates in the
// order this returns, so whatever profile ranks here decides which tracks
// reach the playlist. Scoring with daily_mix here and re-scoring with
// songs_like afterwards would have let the new profile relabel tracks it had
// no part in choosing — inert where it matters (#3881).
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, weights recommendation.ScoringWeights) []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, 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,
// Household co-play (#1533): a deeper collaborative slice for the
// taste surfaces. Empty on single-user servers.
UserCoplay: 40,
}
}
// 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 songsLikeSeedCount "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)
// Once per build rather than once per seed artist — six mixes would
// otherwise mean six full-table counts for a number that cannot have
// changed between them.
librarySize := systemLibrarySize.Get(ctx, func(c context.Context) (int64, error) {
return recommendation.CountLibraryTracks(c, q)
})
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}
// Songs-like's own pool shape, not the default (#3881). Same total
// size; the composition shifts toward arms that actually measure
// distance from the seed. The default gave ~29% of candidates a
// sim_score of literally 0.
//
// Then scaled to the library (#3880): a bigger collection should put
// more genuinely-similar candidates in reach, not the same ~170
// regardless. The weights still rank sim_score-0 arms last, so the
// growth reaches coherence rather than working against it.
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack},
recommendation.ScaleForLibrary(
recommendation.SongsLikeCandidateSourceLimits(), librarySize,
),
)
if cerr != nil {
logger.Warn("system playlist: seed candidates load failed; skipping",
"artist_id", uuidStringPL(artistID), "err", cerr)
continue
}
// The seed artist's own songs are ELIGIBLE here, deliberately.
//
// This used to filter them out — "Songs like X excludes X's own
// songs" — which reads as obviously right and is not. The seed is a
// TRACK, and the tracks most likely to sound like it are usually the
// rest of that artist's catalogue; excluding them threw away the
// nearest neighbours of the very thing the mix is built around, and
// then reached further out to replace them. On a surface whose whole
// job is staying in one neighbourhood, that is backwards.
//
// Operator, 2026-09-10: "it should also be able to include music from
// the same artist."
//
// Domination is bounded by the cap rather than by exclusion, which is
// the distinction that makes this safe: capCandidatesByAlbumAndArtist
// inside pickTopN allows at most discoverMaxTracksPerArtist (3) of a
// 25-track mix — 12%, a presence rather than a takeover. The seed
// track itself still cannot appear; it is passed as an exclusion to
// LoadCandidatesFromSimilarity above.
tracks := pickTopN(cands, 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 songsLikeSeedCount 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 {
// songs_like, not daily_mix (#3881). produceSeedMixes is this function's
// only caller, so the switch moves exactly one surface — For-You ranks
// through pickHeadAndTail and keeps the broader daily_mix profile.
//
// The SAME profile does the selection sort and the final score. Passing
// one and using the other is the subtle version of this bug: the playlist
// would still be chosen by daily_mix and merely wear songs_like numbers.
weights := currentSongsLikeWeights()
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
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, 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 {
// daily_mix — For-You is the broad surface and keeps the roaming profile.
weights := currentSystemMixWeights()
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
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, 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])
}