48f288e2e5
Both mixes move from a single hard eligibility rule to the tiered ladder, with their tier stamped onto playlist_tracks.pick_kind via the #1270 provenance pipeline. New for you (#1267) — consume on play, degrade by stepping back: - "Consumed" = any track attempted >=30s; played albums leave the mix at the next build instead of crowding it until the calendar window expires. - Tier 1: unconsumed albums added <30d by direct-affinity artists. Tier 2: unconsumed affinity albums from the wider 30-90d window — added while you weren't looking. Tier 3: any unconsumed album added <90d, newest first. First listens (#1268) — track-level "attempted" threshold: - A 2-second accidental brush no longer disqualifies a whole album; "attempted" is duration_played_ms >= 30000 per track. - Tier 1: albums with zero attempted tracks. Tier 2: barely-attempted albums (<=25% of tracks reached 30s), minus the attempted tracks themselves. The artist-affinity ordering signal also moves to the >=30s definition so skip-only contact doesn't read as trust. Producer plumbing: fetch adapters map the tier column onto pick kinds, finishMix propagates PickKind into the persisted candidates, and rotateForDay now rotates within contiguous same-pick-kind blocks so daily rotation can't hoist tier-3 filler above tier-1's exact fits (untiered pools are one block — original behavior). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
310 lines
11 KiB
Go
310 lines
11 KiB
Go
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, 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 / NewForYou / FirstListens — SQL accepts only $1
|
|
// user_id and produces deterministic ordering. `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 / NewForYou which
|
|
// are album-coherent — rotation walks the album boundary cleanly
|
|
// rather than scrambling within an album).
|
|
//
|
|
// Diversity is `true` for every mix: per-album <= 2 / per-artist <= 3.
|
|
// On thin libraries where the cap would chop the pool below 100,
|
|
// finishMix tops up from the uncapped raw pool so the mix still
|
|
// ships a full-length playlist — see topUpFromRaw.
|
|
|
|
// 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
|
|
|
|
// Minimum viable mix sizes (issue #1246): below the floor the variant
|
|
// is withheld entirely so Home renders its "listen more to unlock"
|
|
// placeholder — a playlist with a couple of songs reads as a build
|
|
// bug, not a mix. Album-coherent mixes (NewForYou / FirstListens) get
|
|
// a lower floor because one legitimate new album (~5+ tracks) is a
|
|
// useful mix on its own; the scattered mixes need more to feel real.
|
|
const (
|
|
discoveryMixMinLen = 15
|
|
discoveryMixMinLenAlbum = 5
|
|
)
|
|
|
|
// 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
|
|
|
|
// minLen is the minimum viable mix size: a finished pool below it
|
|
// is withheld (no playlist row) so the client renders the locked
|
|
// placeholder instead of a mix that looks built-wrong (#1246).
|
|
minLen int
|
|
|
|
// 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)
|
|
}
|
|
tracks := finishMix(pool, spec.diversify)
|
|
if len(tracks) < spec.minLen {
|
|
logger.Info("system playlist: "+spec.variant+" below minimum viable size; withholding",
|
|
"user_id", uuidStringPL(userID), "pool", len(tracks), "min", spec.minLen)
|
|
return nil, nil
|
|
}
|
|
return emit(spec.name, spec.variant, tracks), nil
|
|
}
|
|
}
|
|
|
|
// rotateForDay rotates the pool left by a daily-deterministic offset
|
|
// so each day's downstream truncate-to-N surfaces a different slice
|
|
// while contiguous-block ordering inside the slice is preserved.
|
|
//
|
|
// Rotation happens WITHIN each contiguous same-pick-kind block
|
|
// (#1267): tiered mixes arrive tier-ordered, and a whole-pool
|
|
// rotation would hoist tier-3 filler above tier-1's exact fits.
|
|
// Untiered pools are a single block, which reduces to the original
|
|
// whole-pool rotation (same seed, same first draw). 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))))
|
|
out := make([]discoverTrack, 0, n)
|
|
for start := 0; start < n; {
|
|
end := start + 1
|
|
for end < n && pool[end].PickKind == pool[start].PickKind {
|
|
end++
|
|
}
|
|
block := pool[start:end]
|
|
if len(block) > 1 {
|
|
offset := rng.Intn(len(block))
|
|
out = append(out, block[offset:]...)
|
|
out = append(out, block[:offset]...)
|
|
} else {
|
|
out = append(out, block...)
|
|
}
|
|
start = end
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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)
|
|
minLen: discoveryMixMinLen,
|
|
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
|
|
minLen: discoveryMixMinLen,
|
|
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: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes
|
|
minLen: discoveryMixMinLenAlbum,
|
|
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,
|
|
PickKind: pickKindForMixTier(r.Tier),
|
|
}
|
|
}
|
|
return out, nil
|
|
},
|
|
},
|
|
{
|
|
name: "On this day", variant: "on_this_day",
|
|
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
|
|
minLen: discoveryMixMinLen,
|
|
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: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up
|
|
minLen: discoveryMixMinLenAlbum,
|
|
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,
|
|
PickKind: pickKindForMixTier(r.Tier),
|
|
}
|
|
}
|
|
return out, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
// finishMix applies diversity caps (per-album <= 2 / per-artist <= 3)
|
|
// when diversify is set, with a top-up fallback when caps strip the
|
|
// pool below discoveryMixLen: the capped result is filled out with
|
|
// non-capped tracks (preserving original SQL order) until the target
|
|
// is hit or the raw pool runs out.
|
|
//
|
|
// The fallback matters on small / album-heavy libraries — the cap
|
|
// can chop a 200-row pool down to 40, and we'd rather ship a partly-
|
|
// diversified 100 than a strictly-diversified 40. On rich libraries
|
|
// the cap yields >= 100 and the top-up path never runs.
|
|
func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
|
|
var pool []discoverTrack
|
|
if diversify {
|
|
capped := capByAlbumAndArtist(rows)
|
|
if len(capped) >= discoveryMixLen {
|
|
pool = capped[:discoveryMixLen]
|
|
} else {
|
|
pool = topUpFromRaw(capped, rows, discoveryMixLen)
|
|
}
|
|
} else {
|
|
pool = rows
|
|
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, PickKind: t.PickKind}
|
|
}
|
|
return tracks
|
|
}
|
|
|
|
// topUpFromRaw appends non-capped tracks from raw (in their original
|
|
// order) onto capped, skipping any already present, until the result
|
|
// reaches target or raw is exhausted. Preserves SQL ranking semantics
|
|
// for the non-diverse fill so the topped-up tail still trends best-
|
|
// first within each album.
|
|
func topUpFromRaw(capped, raw []discoverTrack, target int) []discoverTrack {
|
|
if len(capped) >= target {
|
|
return capped[:target]
|
|
}
|
|
seen := make(map[pgtype.UUID]struct{}, len(capped))
|
|
for _, t := range capped {
|
|
seen[t.ID] = struct{}{}
|
|
}
|
|
pool := capped
|
|
for _, t := range raw {
|
|
if _, in := seen[t.ID]; in {
|
|
continue
|
|
}
|
|
pool = append(pool, t)
|
|
if len(pool) >= target {
|
|
break
|
|
}
|
|
}
|
|
return pool
|
|
}
|
|
|
|
// 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}}
|
|
}
|