// 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" "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]) } // 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 0 because daily determinism comes from tieBreakHash; // any randomness would defeat the within-day stability invariant. var systemMixWeights = recommendation.ScoringWeights{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, SkipPenalty: 2.0, JitterMagnitude: 0.0, ContextWeight: 0.5, SimilarityWeight: 1.5, } // noopRNG returns 0 for any call. Combined with JitterMagnitude=0 it makes // recommendation.Score fully deterministic. func noopRNG() float64 { return 0 } // forYouHeadN is the number of top-scored tracks that anchor the For-You // playlist. forYouTailN is the number of diversity picks sampled from the // tail of the score-sorted pool (positions 2*forYouHeadN onward), injected // after the head to give users a daily-deterministic surprise without // compromising quality. Songs-like-X keeps simple pickTopN (the seed-artist // context already frames the "you'll like this" promise). const ( forYouHeadN = 20 forYouTailN = 5 ) // scoreAndSortCandidates scores every candidate with recommendation.Score // and returns a new slice sorted by score DESC (ties broken by // tieBreakHash). Pure — no truncation, no cap. func scoreAndSortCandidates(cands []recommendation.Candidate, dateStr string, now time.Time) []recommendation.Candidate { 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, noopRNG)} } 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 } // 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) } } }() // 1. For-You: synth seed from top recently-played track, pull candidates. forYouSeed, err := q.PickTopPlayedTrackForUser(ctx, userID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { buildErr = fmt.Errorf("pick for-you seed: %w", err) return buildErr } var forYouTracks []rankedCandidate if err == nil && forYouSeed.Valid { 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 { // For-You uses head+tail composition: forYouHeadN top-similarity // tracks + forYouTailN tail-sampled to inject daily-deterministic // surprise. Songs-like-X keeps pickTopN (top-25 with caps) since // the seed-artist context already provides the "you'll like this" // framing. forYouTracks = pickHeadAndTail(cands, dateStr, now, forYouHeadN, forYouTailN) } else { logger.Warn("system playlist: for-you candidates load failed; skipping", "user_id", uuidStringPL(userID), "err", cerr) } } // 2. Seed artists for "Songs like {X}". seedRows, err := q.PickSeedArtists(ctx, userID) if err != nil { buildErr = fmt.Errorf("pick seed artists: %w", err) return buildErr } seedRowsLocal := make([]seedArtistRow, 0, len(seedRows)) for _, r := range seedRows { seedRowsLocal = append(seedRowsLocal, seedArtistRow{ ArtistID: r.ArtistID, Score: r.Score, }) } seeds := pickSeedArtistsFromRows(seedRowsLocal) type seedMix struct { ArtistID pgtype.UUID ArtistName string Tracks []rankedCandidate } seedMixes := make([]seedMix, 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 } // Keep only tracks NOT by the seed artist (we want "songs like X", // not 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, dateStr, now, systemMixLength) seedMixes = append(seedMixes, seedMix{ ArtistID: artistID, ArtistName: artistRow.Name, Tracks: tracks, }) } // 4. Discover: surface unheard tracks, biased toward dormant artists. discoverTracks, derr := buildDiscoverCandidates(ctx, q, userID, dateStr) if derr != nil { logger.Warn("system playlist: discover candidates load failed; skipping", "user_id", uuidStringPL(userID), "err", derr) discoverTracks = nil } // 3. 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, 1+len(seedMixes)) if len(forYouTracks) > 0 { id, err := insertSystemPlaylist(ctx, qtx, userID, "For You", "for_you", pgtype.UUID{}, forYouTracks) if err != nil { buildErr = fmt.Errorf("insert for-you: %w", err) return buildErr } createdIDs = append(createdIDs, id) } for _, m := range seedMixes { if len(m.Tracks) == 0 { continue } name := fmt.Sprintf("Songs like %s", m.ArtistName) id, err := insertSystemPlaylist(ctx, qtx, userID, name, "songs_like_artist", m.ArtistID, m.Tracks) if err != nil { buildErr = fmt.Errorf("insert songs-like %s: %w", uuidStringPL(m.ArtistID), err) return buildErr } createdIDs = append(createdIDs, id) } if len(discoverTracks) > 0 { id, err := insertSystemPlaylist(ctx, qtx, userID, "Discover", "discover", pgtype.UUID{}, discoverTracks) if err != nil { buildErr = fmt.Errorf("insert discover: %w", 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, dateStr string, now time.Time, n int) []rankedCandidate { sorted := scoreAndSortCandidates(cands, dateStr, now) capped := capCandidatesByAlbumAndArtist(sorted) if len(capped) > n { capped = capped[:n] } out := make([]rankedCandidate, len(capped)) for i, c := range capped { out[i] = rankedCandidate{ TrackID: c.Track.ID, Score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG), } } 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, dateStr string, now time.Time, headN, tailN int) []rankedCandidate { sorted := scoreAndSortCandidates(cands, dateStr, now) capped := capCandidatesByAlbumAndArtist(sorted) 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, noopRNG), } } 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, noopRNG), } } 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 "" } 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]) }