test-go / test (push) Successful in 1m16s
test-go / integration (push) Failing after 3m55s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: "it should also be able to include music from the same artist." Completes #3881 — the weights and pool landed in f367eeaa; this is the eligibility half. produceSeedMixes filtered the seed artist out entirely: // "Songs like X" excludes X's own songs. if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) { ... } That reads as obviously right and is not. The seed is a TRACK — the artist's top-played one — and the tracks most likely to sound like it are usually the rest of that artist's catalogue. The filter threw away the seed's nearest neighbours, then reached FURTHER OUT to replace them. On the one surface whose job is staying in a neighbourhood, that is backwards, and it worked against the coherence tuning rather than with it. Domination is bounded by the cap instead of by exclusion, which is the distinction that makes this safe rather than a new problem: capCandidatesByAlbumAndArtist already allows at most 3 tracks per artist in a 25-track mix, so the seed artist gets 12% at most — a presence, not a takeover. Without that bound this would just be the radio failure (#3882) arriving on a different surface. The seed track itself still cannot appear; it is passed to LoadCandidatesFromSimilarity as an exclusion. Guarded end-to-end rather than by reading the source, for two reasons: the check has to survive the filter returning in a different shape, and an absence check would now match the comment that explains why the filter is gone — rule 167's prose trap exactly. The test asserts both directions, that at least one mix contains its seed artist and that none exceeds the cap. Its falsification is by construction rather than by execution: under the previous code every mix's own-artist count was necessarily zero, so the assertion could not have passed. Running it needs Postgres, which is the integration lane's job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
1084 lines
40 KiB
Go
1084 lines
40 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()
|
|
)
|
|
|
|
// 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)
|
|
|
|
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.
|
|
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
|
ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack},
|
|
recommendation.SongsLikeCandidateSourceLimits(),
|
|
)
|
|
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])
|
|
}
|