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
|
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 +
|
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
|
||||||
// up to 3 Songs-like-{seed} mixes). Atomic-replace inside one tx;
|
// up to 3 Songs-like-{seed} mixes). Atomic-replace inside one tx;
|
||||||
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
|
// 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.
|
// Run every registered system-playlist producer. Each returns the
|
||||||
// Seed rotates daily via userIDHash so the candidate pool shifts
|
// playlists it wants materialized for this user/day (zero or more);
|
||||||
// across days while staying stable within a day.
|
// a returned error is fatal to the whole build (matches the prior
|
||||||
forYouSeeds, err := q.PickTopPlayedTracksForUser(ctx, userID)
|
// behavior where a seed-query failure aborted). Per-item issues are
|
||||||
if err != nil {
|
// logged inside the producer and just yield fewer playlists.
|
||||||
buildErr = fmt.Errorf("pick for-you seed candidates: %w", err)
|
var built []builtPlaylist
|
||||||
return buildErr
|
for _, kind := range systemPlaylistRegistry {
|
||||||
}
|
out, perr := kind.Produce(ctx, q, logger, userID, dateStr, now)
|
||||||
forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr)
|
if perr != nil {
|
||||||
var forYouTracks []rankedCandidate
|
buildErr = fmt.Errorf("produce %s: %w", kind.Key, perr)
|
||||||
if forYouSeed.Valid {
|
return buildErr
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
built = append(built, out...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Seed artists for "Songs like {X}". SQL returns up to 5 candidates
|
// Atomic replace inside a transaction.
|
||||||
// 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.
|
|
||||||
tx, err := pool.Begin(ctx)
|
tx, err := pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
buildErr = fmt.Errorf("begin tx: %w", err)
|
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
|
// for each one after the transaction commits. Collage generation
|
||||||
// reads the playlist's tracks via a separate query that won't see
|
// reads the playlist's tracks via a separate query that won't see
|
||||||
// uncommitted rows, so the work happens post-commit.
|
// 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 {
|
for _, bp := range built {
|
||||||
id, err := insertSystemPlaylist(ctx, qtx, userID, "For You", "for_you",
|
if len(bp.Tracks) == 0 {
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("Songs like %s", m.ArtistName)
|
id, err := insertSystemPlaylist(ctx, qtx, userID, bp.Name, bp.Variant,
|
||||||
id, err := insertSystemPlaylist(ctx, qtx, userID, name, "songs_like_artist",
|
bp.SeedArtistID, bp.Tracks)
|
||||||
m.ArtistID, m.Tracks)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
buildErr = fmt.Errorf("insert songs-like %s: %w", uuidStringPL(m.ArtistID), err)
|
buildErr = fmt.Errorf("insert %s (%s): %w", bp.Variant, bp.Name, 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
|
return buildErr
|
||||||
}
|
}
|
||||||
createdIDs = append(createdIDs, id)
|
createdIDs = append(createdIDs, id)
|
||||||
|
|||||||
Reference in New Issue
Block a user