diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 7bb03c20..faf8dc6d 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -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)