// 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" "errors" "fmt" "hash/fnv" "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 in mix candidate ranking. Same inputs always // produce the same output; different inputs almost always differ. // // Uses FNV-1a 64-bit (stdlib hash/fnv) — fast, deterministic, no extra // dependencies. The exact hash isn't load-bearing; any deterministic // 64-bit hash works. func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 { h := fnv.New64a() if trackID.Valid { _, _ = h.Write(trackID.Bytes[:]) } _, _ = h.Write([]byte(dateStr)) return h.Sum64() } // 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 } // stableSortByScoreThenHash sorts candidates in-place by score DESC, // breaking ties with tieBreakHash(track_id, dateStr). func stableSortByScoreThenHash(cands []rankedCandidate, dateStr string) { sort.SliceStable(cands, func(i, j int) bool { if cands[i].Score != cands[j].Score { return cands[i].Score > cands[j].Score } return tieBreakHash(cands[i].TrackID, dateStr) < tieBreakHash(cands[j].TrackID, dateStr) }) } 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 } // 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) 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 { forYouTracks = pickTopN(cands, dateStr, now, systemMixLength) } 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 CoverPath *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) coverPath, _ := q.PickTopAlbumCoverForArtistByUser(ctx, dbq.PickTopAlbumCoverForArtistByUserParams{ UserID: userID, ArtistID: artistID, }) seedMixes = append(seedMixes, seedMix{ ArtistID: artistID, ArtistName: artistRow.Name, CoverPath: coverPath, Tracks: tracks, }) } // 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 } if len(forYouTracks) > 0 { if err := insertSystemPlaylist(ctx, qtx, userID, "For You", "for_you", pgtype.UUID{}, nil, forYouTracks); err != nil { buildErr = fmt.Errorf("insert for-you: %w", err) return buildErr } } for _, m := range seedMixes { if len(m.Tracks) == 0 { continue } name := fmt.Sprintf("Songs like %s", m.ArtistName) if err := insertSystemPlaylist(ctx, qtx, userID, name, "songs_like_artist", m.ArtistID, m.CoverPath, m.Tracks); err != nil { buildErr = fmt.Errorf("insert songs-like %s: %w", uuidStringPL(m.ArtistID), err) return buildErr } } if err := tx.Commit(ctx); err != nil { buildErr = fmt.Errorf("commit tx: %w", err) return buildErr } return nil } // pickTopN ranks candidates with recommendation.Score, sorts by score-DESC // breaking ties via tieBreakHash(track_id, dateStr), then truncates to N. func pickTopN(cands []recommendation.Candidate, dateStr string, now time.Time, n int) []rankedCandidate { ranked := make([]rankedCandidate, 0, len(cands)) for _, c := range cands { s := recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG) ranked = append(ranked, rankedCandidate{TrackID: c.Track.ID, Score: s}) } stableSortByScoreThenHash(ranked, dateStr) if len(ranked) > n { ranked = ranked[:n] } return ranked } // 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. // // Pass seed_artist_id with Valid=false (zero pgtype.UUID) for the For-You // variant; the CreateSystemPlaylist query persists NULL. func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID, name, variant string, seedArtistID pgtype.UUID, coverPath *string, tracks []rankedCandidate) error { variantStr := variant p, err := qtx.CreateSystemPlaylist(ctx, dbq.CreateSystemPlaylistParams{ UserID: userID, Name: name, SystemVariant: &variantStr, SeedArtistID: seedArtistID, CoverPath: coverPath, }) if err != nil { return 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 fmt.Errorf("append track %s: %w", uuidStringPL(t.TrackID), err) } } if err := qtx.UpdatePlaylistRollups(ctx, p.ID); err != nil { return fmt.Errorf("update rollups: %w", err) } return 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]) }