1379595e82
Behavior-preserving prep for the new mix types. Extracts the three
inline candidate computations in BuildSystemPlaylists into
producers (produceForYou / produceSeedMixes / produceDiscover) and
drives the build off a systemPlaylistRegistry. The shared
machinery (run-claim guard, atomic delete+insert tx, post-commit
collages) is now generic over a []builtPlaylist.
Fatal-vs-skip error semantics unchanged: a base query failure
(PickTopPlayedTracksForUser, PickSeedArtists) still aborts the
whole build; candidate-load / per-seed-artist / Discover-bucket
failures are still logged and just yield fewer playlists.
Materialize order (for_you, songs_like_artist, discover) is
unchanged and functionally irrelevant.
No API/client/schema change — CI's system/foryou/service tests
verify For You / Songs-like-X / Discover parity. Adding a new mix
is now: a producer + one registry entry + its candidate query.
Next (R2): generic /api/playlists/system/{kind}/{refresh,shuffle}
off the registry; then the new kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
655 lines
24 KiB
Go
655 lines
24 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 (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
|
)
|
|
|
|
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
|
|
// Defined locally so unit tests don't need a real DB.
|
|
type seedArtistRow struct {
|
|
ArtistID pgtype.UUID
|
|
Score int64
|
|
}
|
|
|
|
// pickSeedArtistsFromRows projects sqlc rows into the seed list. The
|
|
// SQL already orders by score DESC + artist_id and LIMIT 3, so this is
|
|
// just a column projection — but pulling it into a function keeps the
|
|
// call-site readable and makes the post-fetch path testable without
|
|
// a database.
|
|
func pickSeedArtistsFromRows(rows []seedArtistRow) []pgtype.UUID {
|
|
out := make([]pgtype.UUID, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, r.ArtistID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// tieBreakHash returns a deterministic 64-bit hash of (track_id, date_str).
|
|
// Used to break score ties and to drive the For-You tail sample's
|
|
// daily-deterministic ordering. Same inputs always produce the same
|
|
// output; different inputs almost always differ.
|
|
//
|
|
// Switched from FNV-1a (May 2026) because two date strings differing
|
|
// only in the last character (e.g. "2026-05-07" vs "2026-05-08") gave
|
|
// hash values whose RELATIVE ORDERING across our small candidate pools
|
|
// was identical — the dateStr-driven divergence concentrated in low
|
|
// bits that lost out to high-bit ordering during sort. SHA-256
|
|
// truncated to 8 bytes has full avalanche, so any single-byte input
|
|
// change roughly half-flips the output bits and reorders meaningfully.
|
|
// The hash isn't security-load-bearing; we just want strong avalanche
|
|
// for tiny input deltas.
|
|
func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 {
|
|
h := sha256.New()
|
|
if trackID.Valid {
|
|
_, _ = h.Write(trackID.Bytes[:])
|
|
}
|
|
_, _ = h.Write([]byte(dateStr))
|
|
sum := h.Sum(nil)
|
|
return binary.BigEndian.Uint64(sum[:8])
|
|
}
|
|
|
|
// userIDHash returns a deterministic 64-bit hash of (user_id, dateStr).
|
|
// Same family as tieBreakHash, just keyed on user_id instead of
|
|
// track_id. Used to drive daily-deterministic seed picks and to seed
|
|
// the per-build scoring RNG: same (user, day) → same hash; different
|
|
// days → different hashes.
|
|
func userIDHash(userID pgtype.UUID, dateStr string) uint64 {
|
|
h := sha256.New()
|
|
if userID.Valid {
|
|
_, _ = h.Write(userID.Bytes[:])
|
|
}
|
|
_, _ = h.Write([]byte(dateStr))
|
|
sum := h.Sum(nil)
|
|
return binary.BigEndian.Uint64(sum[:8])
|
|
}
|
|
|
|
// pickForYouSeedForDay picks one of the user's top-played candidate
|
|
// tracks as today's For-You seed. With 5 candidates and SHA-256-based
|
|
// rotation, each candidate gets picked roughly 1 day in 5; the chosen
|
|
// seed drives the similarity candidate pool, so the resulting mix is
|
|
// substantially different across days for a user with diverse top-N
|
|
// plays.
|
|
//
|
|
// Degrades gracefully: 1 candidate → returns it; 0 → returns zero UUID
|
|
// (caller treats as "no seed available" and skips For-You).
|
|
func pickForYouSeedForDay(seeds []pgtype.UUID, userID pgtype.UUID, dateStr string) pgtype.UUID {
|
|
if len(seeds) == 0 {
|
|
return pgtype.UUID{}
|
|
}
|
|
idx := int(userIDHash(userID, dateStr) % uint64(len(seeds)))
|
|
return seeds[idx]
|
|
}
|
|
|
|
// pickSeedArtistsForDay takes a candidate pool of up to N artists and
|
|
// returns up to 3 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: <3 candidates returns whatever is available in
|
|
// shuffled order.
|
|
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []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]
|
|
})
|
|
n := 3
|
|
if len(shuffled) < n {
|
|
n = len(shuffled)
|
|
}
|
|
return shuffled[:n]
|
|
}
|
|
|
|
// rankedCandidate is a (track_id, score) pair used during in-memory
|
|
// sorting before insert into playlist_tracks. T5 fills these from
|
|
// recommendation.Candidate scores.
|
|
type rankedCandidate struct {
|
|
TrackID pgtype.UUID
|
|
Score float64
|
|
}
|
|
|
|
const systemMixLength = 25
|
|
|
|
// systemMixWeights are the fixed scoring weights used by the cron worker.
|
|
// 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.
|
|
var systemMixWeights = recommendation.ScoringWeights{
|
|
BaseWeight: 1.0,
|
|
LikeBoost: 2.0,
|
|
RecencyWeight: 1.0,
|
|
SkipPenalty: 2.0,
|
|
JitterMagnitude: 0.1,
|
|
ContextWeight: 0.5,
|
|
SimilarityWeight: 1.5,
|
|
}
|
|
|
|
// 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
|
|
)
|
|
|
|
// 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
|
|
}
|
|
pairs := make([]scored, len(cands))
|
|
for i, c := range cands {
|
|
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)}
|
|
}
|
|
sort.SliceStable(pairs, func(i, j int) bool {
|
|
if pairs[i].score != pairs[j].score {
|
|
return pairs[i].score > pairs[j].score
|
|
}
|
|
return tieBreakHash(pairs[i].c.Track.ID, dateStr) < tieBreakHash(pairs[j].c.Track.ID, dateStr)
|
|
})
|
|
out := make([]recommendation.Candidate, len(pairs))
|
|
for i, p := range pairs {
|
|
out[i] = p.c
|
|
}
|
|
return out
|
|
}
|
|
|
|
// capCandidatesByAlbumAndArtist trims a candidate list so no single
|
|
// album appears more than discoverMaxTracksPerAlbum times and no
|
|
// single artist appears more than discoverMaxTracksPerArtist times.
|
|
// Mirrors capByAlbumAndArtist (which operates on []discoverTrack);
|
|
// here the input/output is []recommendation.Candidate so For-You and
|
|
// Songs-like-X can apply the same diversity caps as Discover.
|
|
//
|
|
// Preserves input order. Reuses the discoverMax* constants —
|
|
// playlists across all three system variants get consistent
|
|
// diversity behavior.
|
|
func capCandidatesByAlbumAndArtist(cands []recommendation.Candidate) []recommendation.Candidate {
|
|
albumCount := map[pgtype.UUID]int{}
|
|
artistCount := map[pgtype.UUID]int{}
|
|
out := make([]recommendation.Candidate, 0, len(cands))
|
|
for _, c := range cands {
|
|
if albumCount[c.Track.AlbumID] >= discoverMaxTracksPerAlbum {
|
|
continue
|
|
}
|
|
if artistCount[c.Track.ArtistID] >= discoverMaxTracksPerArtist {
|
|
continue
|
|
}
|
|
albumCount[c.Track.AlbumID]++
|
|
artistCount[c.Track.ArtistID]++
|
|
out = append(out, c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// builtPlaylist is what a producer hands back: one playlist to
|
|
// materialize. A producer may return zero (no eligible tracks),
|
|
// one (For-You / Discover / each new kind), or many (Songs-like-X,
|
|
// one per seed artist) of these.
|
|
type builtPlaylist struct {
|
|
Name string
|
|
Variant string
|
|
SeedArtistID pgtype.UUID // zero value for non-seeded kinds
|
|
Tracks []rankedCandidate
|
|
}
|
|
|
|
// systemPlaylistProducer computes the playlists for one kind for a
|
|
// given user/day. A returned error is fatal to the whole build
|
|
// (e.g. a required base query failed); per-item issues should be
|
|
// logged and yield fewer playlists, not an error.
|
|
type systemPlaylistProducer func(
|
|
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
|
userID pgtype.UUID, dateStr string, now time.Time,
|
|
) ([]builtPlaylist, error)
|
|
|
|
type systemPlaylistKind struct {
|
|
Key string
|
|
Produce systemPlaylistProducer
|
|
}
|
|
|
|
// 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 = []systemPlaylistKind{
|
|
{Key: "for_you", Produce: produceForYou},
|
|
{Key: "songs_like_artist", Produce: produceSeedMixes},
|
|
{Key: "discover", Produce: produceDiscover},
|
|
}
|
|
|
|
// produceForYou: today's seed from the user's top-5 played tracks
|
|
// (rotates daily via userIDHash), similarity candidate pool, head+
|
|
// tail composition. The base seed query failing is fatal; a
|
|
// candidate-load failure is logged and yields no For-You.
|
|
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)
|
|
}
|
|
forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr)
|
|
if !forYouSeed.Valid {
|
|
return nil, nil
|
|
}
|
|
zeroVec := recommendation.SessionVector{Seed: true}
|
|
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
|
ctx, q, userID, forYouSeed,
|
|
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
|
|
zeroVec,
|
|
[]pgtype.UUID{forYouSeed},
|
|
recommendation.DefaultCandidateSourceLimits(),
|
|
)
|
|
if cerr != nil {
|
|
logger.Warn("system playlist: for-you candidates load failed; skipping",
|
|
"user_id", uuidStringPL(userID), "err", cerr)
|
|
return nil, nil
|
|
}
|
|
tracks := pickHeadAndTail(cands, 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 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)
|
|
}
|
|
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
|
|
}
|
|
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, now 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...)
|
|
}
|
|
|
|
// 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 err := tx.Commit(ctx); err != nil {
|
|
buildErr = fmt.Errorf("commit tx: %w", err)
|
|
return buildErr
|
|
}
|
|
|
|
// Post-commit: generate a 4-cell collage cover for each system
|
|
// playlist from the contributing tracks' album covers. The collage
|
|
// gracefully falls back to the FabledSword glyph in cells whose
|
|
// album lacks cover art, so a partially-covered library still
|
|
// produces a sensible visual rather than an empty placeholder.
|
|
// Errors are non-fatal — the playlist is already committed; a
|
|
// missing collage just leaves cover_path NULL until next build.
|
|
if dataDir != "" {
|
|
for _, plID := range createdIDs {
|
|
if _, cerr := GenerateCollage(ctx, pool, plID, dataDir); cerr != nil {
|
|
logger.Warn("system playlist: collage generation failed",
|
|
"playlist_id", uuidStringPL(plID), "err", cerr)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pickTopN sorts candidates by score DESC, applies per-album (<=2) /
|
|
// per-artist (<=3) diversity caps matching Discover's behavior, then
|
|
// truncates to n. Used by Songs-like-X (and as the fallback inside
|
|
// pickHeadAndTail for small pools).
|
|
func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate {
|
|
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
|
capped := capCandidatesByAlbumAndArtist(sorted)
|
|
if len(capped) > n {
|
|
capped = capped[:n]
|
|
}
|
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
|
out := make([]rankedCandidate, len(capped))
|
|
for i, c := range capped {
|
|
out[i] = rankedCandidate{
|
|
TrackID: c.Track.ID,
|
|
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pickHeadAndTail picks headN from the score-sorted head plus tailN from
|
|
// positions 2*headN onward (the tail), with the tail sampled
|
|
// daily-deterministically via tieBreakHash. 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, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int) []rankedCandidate {
|
|
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
|
capped := capCandidatesByAlbumAndArtist(sorted)
|
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
|
|
|
total := headN + tailN
|
|
if len(capped) <= total {
|
|
// Pool too small for a head/tail split — return up to total entries.
|
|
if len(capped) < total {
|
|
total = len(capped)
|
|
}
|
|
out := make([]rankedCandidate, total)
|
|
for i := 0; i < total; i++ {
|
|
out[i] = rankedCandidate{
|
|
TrackID: capped[i].Track.ID,
|
|
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
head := capped[:headN]
|
|
|
|
tailStart := 2 * headN
|
|
if tailStart >= len(capped) {
|
|
tailStart = headN
|
|
}
|
|
|
|
// Defensive copy so that sorting the tail pool does not mutate capped.
|
|
tailPool := append([]recommendation.Candidate{}, capped[tailStart:]...)
|
|
|
|
// Sort tail pool by tieBreakHash (daily-deterministic), take tailN.
|
|
// Sample is stable across requests within a day but varies across days.
|
|
sort.SliceStable(tailPool, func(i, j int) bool {
|
|
return tieBreakHash(tailPool[i].Track.ID, dateStr) < tieBreakHash(tailPool[j].Track.ID, dateStr)
|
|
})
|
|
tail := tailPool
|
|
if len(tail) > tailN {
|
|
tail = tail[:tailN]
|
|
}
|
|
|
|
// Combine: head order preserved (score-sorted), tail in tieBreakHash
|
|
// order. "First similar, then surprise" reads naturally in playback.
|
|
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 {
|
|
out[i] = rankedCandidate{
|
|
TrackID: c.Track.ID,
|
|
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
|
}
|
|
}
|
|
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 {
|
|
if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
|
|
PlaylistID: p.ID,
|
|
TrackID: t.TrackID,
|
|
}); 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.
|
|
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])
|
|
}
|