package playlists import ( "context" "log/slog" "math/rand" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // Discovery mixes (#419-423). One generic producer + per-mix spec. // Replaces the five near-identical produceXxx functions that all // fetched ranked (id, album_id, artist_id) rows, optionally // diversified, truncated, and emitted a single playlist. // // Day-keying is a per-mix property captured by `dailyRotate`: // // - DeepCuts / OnThisDay — SQL already day-keys via // ORDER BY md5(t.id::text || $2::text), so the Go producer keeps // SQL order. `dailyRotate: false`. // // - Rediscover / FirstListens — SQL accepts only $1 user_id and // produces deterministic ordering. Same content day-over-day // until library state shifts. `dailyRotate: true` applies a // daily-deterministic rotate-left of the pool BEFORE diversify+ // truncate so each day's top-100 surfaces a different slice // while contiguous-block ordering within each slice is preserved // (matters for FirstListens which is album-coherent). // // - NewForYou — SQL produces a newest-album-first ordering whose // intent is "see what's new". Day-over-day same content is // correct UX: the user's "what's new" list shouldn't rotate. The // spec carries `dailyRotate: false` deliberately. // discoveryMixLen caps each mix at the same depth as For-You / // Discover so shuffle-on-play has a varied pool within a day. const discoveryMixLen = 100 // discoveryMixSpec describes one discovery mix. The unified producer // reads the spec and runs a single code path for all variants. type discoveryMixSpec struct { name string variant string diversify bool // dailyRotate, when true, applies a daily-deterministic offset // rotation to the candidate pool BEFORE diversify+truncate so the // top discoveryMixLen rotates day-over-day. Set on variants whose // SQL ORDER is invariant to dateStr (Rediscover, FirstListens). // Leave false when the SQL already day-keys (DeepCuts, OnThisDay) // or when day-over-day stability is the intended UX (NewForYou). dailyRotate bool // fetch returns the raw ranked rows. dateStr is supplied for // queries that accept it (passed as the second positional arg // historically); queries that don't accept it ignore the param. fetch func(context.Context, *dbq.Queries, pgtype.UUID, string) ([]discoverTrack, error) } // produceDiscoveryMix returns a systemPlaylistKind.Produce closure // bound to the given spec. Registered in systemPlaylistRegistry; see // the discoveryMixSpecs slice below for the concrete instances. func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer { return func( ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string, _ time.Time, ) ([]builtPlaylist, error) { rows, err := spec.fetch(ctx, q, userID, dateStr) if err != nil { logger.Warn("system playlist: "+spec.variant+" query failed; skipping", "user_id", uuidStringPL(userID), "err", err) return nil, nil } pool := rows if spec.dailyRotate { pool = rotateForDay(pool, userID, dateStr) } return emit(spec.name, spec.variant, finishMix(pool, spec.diversify)), nil } } // rotateForDay rotates pool left by a daily-deterministic offset so // each day's downstream truncate-to-N surfaces a different slice of // the pool while contiguous-block ordering inside the slice is // preserved. Empty / single-element pools pass through unchanged. func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack { n := len(pool) if n <= 1 { return pool } rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) offset := rng.Intn(n) rotated := make([]discoverTrack, 0, n) rotated = append(rotated, pool[offset:]...) rotated = append(rotated, pool[:offset]...) return rotated } // discoveryMixSpecs is the concrete spec list used by the registry in // system.go. Adding a new mix = one entry here + the candidate query. // // Order has no functional effect (insert-time atomic replace is order // independent); listed in the same order as the historical registry. var discoveryMixSpecs = []discoveryMixSpec{ { name: "Deep Cuts", variant: "deep_cuts", diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2) fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) { rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{ UserID: uid, Column2: ds, }) if err != nil { return nil, err } out := make([]discoverTrack, len(rows)) for i, r := range rows { out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} } return out, nil }, }, { name: "Rediscover", variant: "rediscover", diversify: true, dailyRotate: true, // SQL has no date arg fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) { rows, err := q.ListRediscoverTracks(ctx, uid) if err != nil { return nil, err } out := make([]discoverTrack, len(rows)) for i, r := range rows { out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} } return out, nil }, }, { name: "New for you", variant: "new_for_you", diversify: false, dailyRotate: false, // newest-first is the intent fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) { rows, err := q.ListNewForYouTracks(ctx, uid) if err != nil { return nil, err } out := make([]discoverTrack, len(rows)) for i, r := range rows { out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} } return out, nil }, }, { name: "On this day", variant: "on_this_day", diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2) fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) { rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{ UserID: uid, Column2: ds, }) if err != nil { return nil, err } out := make([]discoverTrack, len(rows)) for i, r := range rows { out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} } return out, nil }, }, { name: "First listens", variant: "first_listens", diversify: false, dailyRotate: true, // SQL has no date arg; album-coherent so rotate (not shuffle) fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) { rows, err := q.ListFirstListensTracks(ctx, uid) if err != nil { return nil, err } out := make([]discoverTrack, len(rows)) for i, r := range rows { out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} } return out, nil }, }, } // finishMix caps (per-album<=2 / per-artist<=3) when diversify is // set, truncates to discoveryMixLen, and converts to the insert // type. Album-coherent mixes (New for you, First Listens) pass // diversify=false so whole albums survive. func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate { pool := rows if diversify { pool = capByAlbumAndArtist(pool) } if len(pool) > discoveryMixLen { pool = pool[:discoveryMixLen] } if len(pool) == 0 { return nil } tracks := make([]rankedCandidate, len(pool)) for i, t := range pool { tracks[i] = rankedCandidate{TrackID: t.ID} } return tracks } // emit wraps the finished track list in a single builtPlaylist (the // discovery mixes are all singletons). nil tracks → no playlist. func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist { if len(tracks) == 0 { return nil } return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}} }