refactor(playlists): #411 R1 — system-playlist kind registry
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>
This commit is contained in:
+178
-130
@@ -225,6 +225,165 @@ func capCandidatesByAlbumAndArtist(cands []recommendation.Candidate) []recommend
|
||||
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
|
||||
@@ -273,112 +432,22 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
||||
}
|
||||
}()
|
||||
|
||||
// 1. For-You: pick today's seed from the user's top-5 played tracks.
|
||||
// Seed rotates daily via userIDHash so the candidate pool shifts
|
||||
// across days while staying stable within a day.
|
||||
forYouSeeds, err := q.PickTopPlayedTracksForUser(ctx, userID)
|
||||
if err != nil {
|
||||
buildErr = fmt.Errorf("pick for-you seed candidates: %w", err)
|
||||
return buildErr
|
||||
}
|
||||
forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr)
|
||||
var forYouTracks []rankedCandidate
|
||||
if 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, userID, dateStr, now, forYouHeadN, forYouTailN)
|
||||
} else {
|
||||
logger.Warn("system playlist: for-you candidates load failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", cerr)
|
||||
// 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...)
|
||||
}
|
||||
|
||||
// 2. Seed artists for "Songs like {X}". SQL returns up to 5 candidates
|
||||
// in score order; pickSeedArtistsForDay shuffles them daily-
|
||||
// deterministically and takes 3 so the set of mixes rotates day-to-day.
|
||||
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,
|
||||
})
|
||||
}
|
||||
seedPool := pickSeedArtistsFromRows(seedRowsLocal)
|
||||
seeds := pickSeedArtistsForDay(seedPool, userID, dateStr)
|
||||
|
||||
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, userID, dateStr, now, systemMixLength)
|
||||
seedMixes = append(seedMixes, seedMix{
|
||||
ArtistID: artistID,
|
||||
ArtistName: artistRow.Name,
|
||||
Tracks: tracks,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Discover: surface unheard tracks, biased toward dormant artists.
|
||||
// Individual bucket failures are logged inside buildDiscoverCandidates
|
||||
// and redistribute as deficit; the function only returns an error for
|
||||
// unrecoverable conditions, so a remaining derr here is genuine.
|
||||
discoverTracks, 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)
|
||||
discoverTracks = nil
|
||||
}
|
||||
|
||||
// 3. Atomic replace inside a transaction.
|
||||
// Atomic replace inside a transaction.
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
buildErr = fmt.Errorf("begin tx: %w", err)
|
||||
@@ -396,37 +465,16 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
||||
// 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))
|
||||
createdIDs := make([]pgtype.UUID, 0, len(built))
|
||||
|
||||
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 {
|
||||
for _, bp := range built {
|
||||
if len(bp.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)
|
||||
id, err := insertSystemPlaylist(ctx, qtx, userID, bp.Name, bp.Variant,
|
||||
bp.SeedArtistID, bp.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)
|
||||
buildErr = fmt.Errorf("insert %s (%s): %w", bp.Variant, bp.Name, err)
|
||||
return buildErr
|
||||
}
|
||||
createdIDs = append(createdIDs, id)
|
||||
|
||||
Reference in New Issue
Block a user