0d0a8f46b1
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
1000 lines
36 KiB
Go
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])
|
|
}
|