feat(server/playlists): Discover system playlist
Adds the third system playlist variant: 'discover'. Surfaces 100 tracks the operator has not played and not liked, biased toward artists they rarely play (< 10 plays) and complemented by tracks liked by other users plus a random unheard sample. Cold start collapses to all-random; single-user servers redistribute the cross-user-likes allocation equally across the other two buckets. The build runs as a fourth phase inside BuildSystemPlaylists alongside For You and the seed-artist mixes. Same daily-deterministic md5(track_id || dateStr) ordering as the other variants. Per-album (<=2) and per-artist (<=3) caps prevent collapse onto a single prolific artist. Buckets interleave round-robin so the playlist order doesn't front-load one bucket's flavour. Post-commit collage generation (already wired) gives Discover the same 4-cell cover treatment as the other system playlists — no new collage code needed. Tests cover the slot-redistribution table (all-available, cold-start, single-user, partial-dormant, fully-empty), the per-album/per-artist caps, the round-robin interleave, and a DB-backed cold-start integration test that asserts a Discover playlist lands with non-zero tracks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
const (
|
||||
discoverTotalSlots = 100
|
||||
discoverDormantSlots = 40
|
||||
discoverCrossUserSlots = 30
|
||||
discoverRandomSlots = 30
|
||||
discoverMaxTracksPerAlbum = 2
|
||||
discoverMaxTracksPerArtist = 3
|
||||
)
|
||||
|
||||
// discoverTrack is the common shape used by the bucket allocator. The
|
||||
// three sqlc-generated row types collapse into this internal struct so
|
||||
// downstream functions don't need to be generic over them.
|
||||
type discoverTrack struct {
|
||||
ID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
}
|
||||
|
||||
// buildDiscoverCandidates assembles the Discover playlist track list.
|
||||
// Pulls from three buckets, applies per-album/per-artist caps, then
|
||||
// redistributes any deficit equally across the remaining buckets.
|
||||
//
|
||||
// Returns up to discoverTotalSlots track IDs in the order they should
|
||||
// appear in the playlist (round-robin interleaved across buckets).
|
||||
// Empty slice when the library has no eligible tracks at all.
|
||||
func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, dateStr string) ([]rankedCandidate, error) {
|
||||
dormantRows, err := q.ListDormantArtistTracksForDiscover(ctx, dbq.ListDormantArtistTracksForDiscoverParams{
|
||||
UserID: userID, Column2: dateStr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dormant bucket: %w", err)
|
||||
}
|
||||
crossUserRows, err := q.ListCrossUserLikedTracksForDiscover(ctx, dbq.ListCrossUserLikedTracksForDiscoverParams{
|
||||
UserID: userID, Column2: dateStr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cross-user bucket: %w", err)
|
||||
}
|
||||
randomRows, err := q.ListRandomUnheardTracksForDiscover(ctx, dbq.ListRandomUnheardTracksForDiscoverParams{
|
||||
UserID: userID, Column2: dateStr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("random bucket: %w", err)
|
||||
}
|
||||
|
||||
// Adapt sqlc-generated rows into the internal struct, then apply
|
||||
// per-album / per-artist caps.
|
||||
dormantPool := capByAlbumAndArtist(dormantRowsToTracks(dormantRows))
|
||||
crossUserPool := capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows))
|
||||
randomPool := capByAlbumAndArtist(randomRowsToTracks(randomRows))
|
||||
|
||||
// Allocate slots with redistribution.
|
||||
allocations := redistributeSlots([]bucketRequest{
|
||||
{want: discoverDormantSlots, available: len(dormantPool)},
|
||||
{want: discoverCrossUserSlots, available: len(crossUserPool)},
|
||||
{want: discoverRandomSlots, available: len(randomPool)},
|
||||
})
|
||||
|
||||
// Take the head of each bucket's capped pool.
|
||||
dormantTake := dormantPool[:allocations[0]]
|
||||
crossUserTake := crossUserPool[:allocations[1]]
|
||||
randomTake := randomPool[:allocations[2]]
|
||||
|
||||
// Round-robin interleave so the playlist doesn't front-load one
|
||||
// flavour.
|
||||
out := interleaveBuckets(dormantTake, crossUserTake, randomTake)
|
||||
|
||||
// Convert to rankedCandidate (the type insertSystemPlaylist accepts).
|
||||
// Score is unused for Discover — the daily-deterministic md5 ordering
|
||||
// already gave us the ranking.
|
||||
ranked := make([]rankedCandidate, 0, len(out))
|
||||
for _, t := range out {
|
||||
ranked = append(ranked, rankedCandidate{TrackID: t.ID})
|
||||
}
|
||||
return ranked, nil
|
||||
}
|
||||
|
||||
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// capByAlbumAndArtist trims a bucket's pool so no single album appears
|
||||
// more than discoverMaxTracksPerAlbum times and no single artist
|
||||
// appears more than discoverMaxTracksPerArtist times. Preserves input
|
||||
// order (which is the daily-deterministic md5 sort).
|
||||
//
|
||||
// pgtype.UUID's Bytes field is [16]byte, which IS comparable as a map
|
||||
// key.
|
||||
func capByAlbumAndArtist(rows []discoverTrack) []discoverTrack {
|
||||
albumCount := map[pgtype.UUID]int{}
|
||||
artistCount := map[pgtype.UUID]int{}
|
||||
out := make([]discoverTrack, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if albumCount[r.AlbumID] >= discoverMaxTracksPerAlbum {
|
||||
continue
|
||||
}
|
||||
if artistCount[r.ArtistID] >= discoverMaxTracksPerArtist {
|
||||
continue
|
||||
}
|
||||
albumCount[r.AlbumID]++
|
||||
artistCount[r.ArtistID]++
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bucketRequest captures one bucket's desired and available count.
|
||||
type bucketRequest struct {
|
||||
want int
|
||||
available int
|
||||
}
|
||||
|
||||
// redistributeSlots returns the final per-bucket allocation given each
|
||||
// bucket's want and available count. When a bucket can't fill its want,
|
||||
// the deficit splits equally between the OTHER buckets that still have
|
||||
// supply. Sum of returned allocations <= sum of `want`.
|
||||
//
|
||||
// Algorithm:
|
||||
// 1. Initial pass: allocate min(want, available) to each bucket;
|
||||
// compute supply = available - allocated.
|
||||
// 2. For each deficit bucket (allocated < want), split the deficit
|
||||
// equally across peers that still have supply > 0.
|
||||
// 3. Repeat until no further movement (deficit redistribution
|
||||
// can create new deficits if the recipient was already at its
|
||||
// `want`; we cap each bucket at its `available` after every pass).
|
||||
//
|
||||
// For 3 buckets, two redistribution passes are sufficient in practice;
|
||||
// we cap at 4 passes defensively.
|
||||
func redistributeSlots(buckets []bucketRequest) []int {
|
||||
n := len(buckets)
|
||||
final := make([]int, n)
|
||||
supply := make([]int, n)
|
||||
for i, b := range buckets {
|
||||
final[i] = b.want
|
||||
if final[i] > b.available {
|
||||
final[i] = b.available
|
||||
}
|
||||
supply[i] = b.available - final[i]
|
||||
}
|
||||
|
||||
for pass := 0; pass < 4; pass++ {
|
||||
anyMoved := false
|
||||
for i, b := range buckets {
|
||||
deficit := b.want - final[i]
|
||||
if deficit <= 0 {
|
||||
continue
|
||||
}
|
||||
// Find peers with supply.
|
||||
peers := make([]int, 0, n-1)
|
||||
for j := 0; j < n; j++ {
|
||||
if j == i {
|
||||
continue
|
||||
}
|
||||
if supply[j] > 0 {
|
||||
peers = append(peers, j)
|
||||
}
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
continue
|
||||
}
|
||||
share := deficit / len(peers)
|
||||
rem := deficit % len(peers)
|
||||
for k, j := range peers {
|
||||
take := share
|
||||
if k < rem {
|
||||
take++
|
||||
}
|
||||
if take > supply[j] {
|
||||
take = supply[j]
|
||||
}
|
||||
if take > 0 {
|
||||
final[j] += take
|
||||
supply[j] -= take
|
||||
anyMoved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !anyMoved {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Final clamp: total <= discoverTotalSlots.
|
||||
sum := 0
|
||||
for _, f := range final {
|
||||
sum += f
|
||||
}
|
||||
for sum > discoverTotalSlots {
|
||||
// Trim the largest bucket. Should be rare.
|
||||
maxVal, idx := -1, -1
|
||||
for i, f := range final {
|
||||
if f > maxVal {
|
||||
maxVal, idx = f, i
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
final[idx]--
|
||||
sum--
|
||||
}
|
||||
return final
|
||||
}
|
||||
|
||||
// interleaveBuckets round-robins items from the buckets in order. The
|
||||
// first item of each bucket appears before the second of any bucket.
|
||||
func interleaveBuckets(buckets ...[]discoverTrack) []discoverTrack {
|
||||
total := 0
|
||||
for _, b := range buckets {
|
||||
total += len(b)
|
||||
}
|
||||
out := make([]discoverTrack, 0, total)
|
||||
indices := make([]int, len(buckets))
|
||||
for {
|
||||
anyAppended := false
|
||||
for bi, b := range buckets {
|
||||
if indices[bi] < len(b) {
|
||||
out = append(out, b[indices[bi]])
|
||||
indices[bi]++
|
||||
anyAppended = true
|
||||
}
|
||||
}
|
||||
if !anyAppended {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user