c7ee0871a5
Each is one candidate query + one registry entry; zero client work (R2 made tiles/refresh/shuffle generic, all are singleton kinds so web's server `refreshable` flag and Flutter's derived getter both light up automatically). - deep_cuts (#419): <=2-play tracks from liked / heavily-played artists; diversity-capped. - rediscover (#420): >=5-play tracks not heard in 6 months, by historical affection. - new_for_you (#421): tracks from albums added <=30d whose artist the user likes/plays; album-coherent (no cap). - on_this_day (#422): tracks played within ±7 day-of-year in prior windows (>60d ago), weighted by play count. - first_listens (#423): never-played albums, tiered liked-artist → played-artist → rest; album-coherent. system_mixes.go producers mirror the Discover model (SQL gives the ranking; finishMix caps+truncates to 100 to match For-You/Discover shuffle depth; album-coherent mixes skip the cap). Builder query failure is non-fatal (logged, yields no playlist) like Discover. Existing system_test existence checks are unaffected. Closes the #411 system-playlists-v2 umbrella's new-types thread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
146 lines
4.8 KiB
Go
146 lines
4.8 KiB
Go
package playlists
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"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.
|
|
|
|
// 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
|
|
|
|
// 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}}
|
|
}
|
|
|
|
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
|
|
}
|