diff --git a/internal/playlists/system.go b/internal/playlists/system.go index fc55eed6..b2eeead5 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -278,16 +278,23 @@ func RefreshableSystemKind(key string) bool { // 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", Singleton: true, Produce: produceForYou}, - {Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes}, - {Key: "discover", Singleton: true, Produce: produceDiscover}, - {Key: "deep_cuts", Singleton: true, Produce: produceDeepCuts}, - {Key: "rediscover", Singleton: true, Produce: produceRediscover}, - {Key: "new_for_you", Singleton: true, Produce: produceNewForYou}, - {Key: "on_this_day", Singleton: true, Produce: produceOnThisDay}, - {Key: "first_listens", Singleton: true, Produce: produceFirstListens}, -} +var systemPlaylistRegistry = func() []systemPlaylistKind { + out := []systemPlaylistKind{ + {Key: "for_you", Singleton: true, Produce: produceForYou}, + {Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes}, + {Key: "discover", Singleton: true, Produce: produceDiscover}, + } + // The five discovery mixes share one Produce closure factory keyed + // by a per-mix spec; spec list + factory live in system_mixes.go. + for _, spec := range discoveryMixSpecs { + out = append(out, systemPlaylistKind{ + Key: spec.variant, + Singleton: true, + Produce: produceDiscoveryMix(spec), + }) + } + return out +}() // systemForYouSourceLimits is a deeper candidate pool than the radio // default. On a self-hosted library without ListenBrainz similarity diff --git a/internal/playlists/system_mixes.go b/internal/playlists/system_mixes.go index e57233ea..19e05dcb 100644 --- a/internal/playlists/system_mixes.go +++ b/internal/playlists/system_mixes.go @@ -3,6 +3,7 @@ package playlists import ( "context" "log/slog" + "math/rand" "time" "github.com/jackc/pgx/v5/pgtype" @@ -10,18 +11,181 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) -// Discovery mixes (#419-423). Each is one candidate query + a thin -// producer registered in systemPlaylistRegistry. They follow the -// Discover model: a SQL query returns ordered (id, album_id, -// artist_id) rows; we optionally diversity-cap, truncate, and emit -// rankedCandidate (Score unused — SQL gave the ranking). All are -// singleton kinds, so the generic by-kind refresh/shuffle endpoints -// and per-tile refresh affordance work with zero client changes. +// 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 @@ -52,94 +216,3 @@ func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist { } return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}} } - -func produceDeepCuts( - ctx context.Context, q *dbq.Queries, logger *slog.Logger, - userID pgtype.UUID, dateStr string, _ time.Time, -) ([]builtPlaylist, error) { - rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{ - UserID: userID, Column2: dateStr, - }) - if err != nil { - logger.Warn("system playlist: deep-cuts query failed; skipping", - "user_id", uuidStringPL(userID), "err", err) - return nil, nil - } - dt := make([]discoverTrack, len(rows)) - for i, r := range rows { - dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} - } - return emit("Deep Cuts", "deep_cuts", finishMix(dt, true)), nil -} - -func produceRediscover( - ctx context.Context, q *dbq.Queries, logger *slog.Logger, - userID pgtype.UUID, _ string, _ time.Time, -) ([]builtPlaylist, error) { - rows, err := q.ListRediscoverTracks(ctx, userID) - if err != nil { - logger.Warn("system playlist: rediscover query failed; skipping", - "user_id", uuidStringPL(userID), "err", err) - return nil, nil - } - dt := make([]discoverTrack, len(rows)) - for i, r := range rows { - dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} - } - return emit("Rediscover", "rediscover", finishMix(dt, true)), nil -} - -func produceNewForYou( - ctx context.Context, q *dbq.Queries, logger *slog.Logger, - userID pgtype.UUID, _ string, _ time.Time, -) ([]builtPlaylist, error) { - rows, err := q.ListNewForYouTracks(ctx, userID) - if err != nil { - logger.Warn("system playlist: new-for-you query failed; skipping", - "user_id", uuidStringPL(userID), "err", err) - return nil, nil - } - dt := make([]discoverTrack, len(rows)) - for i, r := range rows { - dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} - } - // Album-coherent: no diversity cap so whole new albums survive. - return emit("New for you", "new_for_you", finishMix(dt, false)), nil -} - -func produceOnThisDay( - ctx context.Context, q *dbq.Queries, logger *slog.Logger, - userID pgtype.UUID, dateStr string, _ time.Time, -) ([]builtPlaylist, error) { - rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{ - UserID: userID, Column2: dateStr, - }) - if err != nil { - logger.Warn("system playlist: on-this-day query failed; skipping", - "user_id", uuidStringPL(userID), "err", err) - return nil, nil - } - dt := make([]discoverTrack, len(rows)) - for i, r := range rows { - dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} - } - return emit("On this day", "on_this_day", finishMix(dt, true)), nil -} - -func produceFirstListens( - ctx context.Context, q *dbq.Queries, logger *slog.Logger, - userID pgtype.UUID, _ string, _ time.Time, -) ([]builtPlaylist, error) { - rows, err := q.ListFirstListensTracks(ctx, userID) - if err != nil { - logger.Warn("system playlist: first-listens query failed; skipping", - "user_id", uuidStringPL(userID), "err", err) - return nil, nil - } - dt := make([]discoverTrack, len(rows)) - for i, r := range rows { - dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} - } - // Album-coherent (tiered by liked/played artist in SQL): no cap. - return emit("First listens", "first_listens", finishMix(dt, false)), nil -}