Files
minstrel/internal/playlists/system.go
T
bvandeusen 735383fb1d feat(server/playlists): generate cover collages for system playlists
System-generated playlists ("For You", "Songs like X") rendered with
empty placeholder boxes in the UI even when their contributing tracks
had album art. The build path inserted playlist rows directly without
ever invoking the existing playlists.GenerateCollage machinery —
that was only wired into user-edited playlist mutations.

After this change, BuildSystemPlaylists generates a 4-cell collage
for every system playlist it creates, post-commit. Same cover
mechanism user playlists use, same on-disk layout
(<DataDir>/playlist_covers/<id>.jpg). Cells whose source album lacks
art fall back to the FabledSword glyph, so a partially-covered
library still produces a sensible visual.

Threading: BuildSystemPlaylists, StartSystemPlaylistCron, and the
api lazy-build path all gain a dataDir string parameter; main.go
passes cfg.Storage.DataDir.

Drops the now-redundant PickTopAlbumCoverForArtistByUser lookup —
the collage is more visually informative than a single album cover
copy, and the seed-artist's top album is one of the contributing
tracks in the mix anyway, so it'll appear as one of the four cells.

Tests pass t.TempDir() for the dataDir parameter.
2026-05-07 08:14:13 -04:00

369 lines
13 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"
"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, 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 {
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
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,
})
}
// 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 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 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. 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])
}