Files
minstrel/internal/playlists/genre_mixes.go
T
bvandeusenandClaude Opus 5.5 dabe1e052d
release / go (push) Successful in 2m0s
release / web (push) Successful in 1m33s
release / govulncheck (push) Successful in 18s
release / integration (push) Successful in 5m11s
release / android (push) Successful in 4m14s
release / Build signed APK (push) Successful in 4m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: genre mixes pick specific genres that don't share songs (#6933)
The shelf had four mixes built largely from the same lo-fi beats: about
1,250 tracks carry Hip Hop, Lo-Fi, Instrumental, Downtempo and four more
tags at once, and the near-duplicate guard compared artists, not songs, so
each tag passed as its own channel.

Genre selection now works on songs:
- ListGenreMixCandidates returns each genre's track ids instead of its
  artist ids.
- A broad genre, one whose songs mostly also carry clearly narrower
  candidates (Rock over Pop Punk and Ska Punk), is considered after the
  specific genres of its tier.
- A genre is kept only when at least half its songs are not already in a
  kept genre.
- Each mix leaves out songs tagged with a genre kept before it
  (ListGenreMixTracks exclude_genres), so a song is in one mix at most.

The tiers, the minimum shelf and the minimum mix length are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:55:19 -05:00

281 lines
8.9 KiB
Go

package playlists
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Genre mixes: one daily playlist per genre the user listens to, shown on
// their own Home row. Each mix is a blend of the user's familiar tracks in
// that genre and tracks in it they have not heard yet.
//
// Which genres: ListGenreMixCandidates ranks library genres in three tiers
// (taste profile → listening history → the rest of the library). Tier 3
// only fills a shelf the user's own history cannot: a user with a handful of
// listened genres gets those, topped up to genreMixMinShelf, rather than
// ten channels of music they never play.
//
// The shelf is a set of channels that sound different from one another. Tags
// overlap heavily (a lo-fi beat is often tagged Lo-Fi, Hip Hop, Chillwave and
// Instrumental at once), so the pick is made on songs, not names: a genre
// whose songs mostly sit in a genre already picked adds no new channel, and a
// broad genre whose songs mostly carry narrower ones gives way to them, so
// the shelf holds Pop Punk and Ska Punk rather than one Rock mix. Each song
// belongs to at most one mix.
//
// The set of genres is stable from day to day, like a list of channels; the
// tracks inside each mix change daily. ListGenreMixTracks draws them by a
// date-seeded weighted lottery: familiar tracks lean toward the ones played
// most and liked, unheard ones toward the user's artists and artists similar
// to them, and every track keeps some chance.
const (
// genreMixCount is the most genre mixes a user gets.
genreMixCount = 10
// genreMixMinShelf is how many mixes tier-3 genres may fill up to when
// the user's history yields fewer.
genreMixMinShelf = 4
// genreMixLen is each mix's length.
genreMixLen = 50
// genreMixMinLen withholds a genre whose eligible tracks can't make a
// real mix; the next candidate genre takes its place.
genreMixMinLen = 15
// A mix is genreMixFamiliarParts in every genreMixParts tracks the user
// already knows (3 in 5); the rest is unheard tracks in the genre.
// Either side running short is filled from the other.
genreMixFamiliarParts = 3
genreMixParts = 5
// genreMixMinNewShare is the share of a genre's songs that must not be
// in a genre already picked for it to get a mix of its own.
genreMixMinNewShare = 0.5
// A genre is broad when at least genreMixBroadShare of its songs also
// carry a clearly narrower candidate genre: one at most
// genreMixNarrowerSize times its size with at least half of its songs
// inside it. Broad genres are considered after the specific ones of
// their tier.
genreMixBroadShare = 0.5
genreMixNarrowerSize = 0.5
// genreMixTier3 is the tier of genres the user has no history with.
genreMixTier3 = 3
)
// genreCandidate is one row of ListGenreMixCandidates.
type genreCandidate struct {
Key string
Label string
Tier int32
TrackIDs []pgtype.UUID
}
// genreTrack is one row of ListGenreMixTracks.
type genreTrack struct {
discoverTrack
Familiar bool
}
// produceGenreMixes builds up to genreMixCount "{Genre} mix" playlists.
// The candidate query failing is fatal to the build, like the Songs-like
// seed query; a single genre's track query failing skips that genre.
func produceGenreMixes(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, _ time.Time,
) ([]builtPlaylist, error) {
rows, err := q.ListGenreMixCandidates(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list genre mix candidates: %w", err)
}
cands := make([]genreCandidate, len(rows))
for i, r := range rows {
cands[i] = genreCandidate{Key: r.GenreKey, Label: r.Label, Tier: r.Tier, TrackIDs: r.TrackIds}
}
build := func(c genreCandidate, exclude []string) []rankedCandidate {
trackRows, terr := q.ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
UserID: userID,
GenreKey: c.Key,
DateSeed: dateStr,
ExcludeGenres: exclude,
})
if terr != nil {
logger.Warn("system playlist: genre mix tracks query failed; skipping genre",
"user_id", uuidStringPL(userID), "genre", c.Label, "err", terr)
return nil
}
tracks := make([]genreTrack, len(trackRows))
for i, r := range trackRows {
tracks[i] = genreTrack{
discoverTrack: discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID},
Familiar: r.Familiar,
}
}
return composeGenreMix(tracks, genreMixLen)
}
return selectGenreMixes(cands, build), nil
}
// selectGenreMixes walks the candidates, specific genres before broad ones
// within each tier, and keeps the ones that make a real mix of music the
// shelf does not have yet. It takes tier-3 genres only while the shelf is
// below genreMixMinShelf. build is handed the genres kept so far, whose songs
// the new mix leaves out.
func selectGenreMixes(
cands []genreCandidate, build func(genreCandidate, []string) []rankedCandidate,
) []builtPlaylist {
sets := make([]map[pgtype.UUID]struct{}, len(cands))
for i, c := range cands {
sets[i] = make(map[pgtype.UUID]struct{}, len(c.TrackIDs))
for _, id := range c.TrackIDs {
sets[i][id] = struct{}{}
}
}
owned := map[pgtype.UUID]struct{}{}
kept := []string{}
var out []builtPlaylist
for _, i := range specificFirst(cands, sets) {
c := cands[i]
if len(out) >= genreMixCount {
break
}
if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf {
break
}
if !mostlyNew(sets[i], owned) {
continue
}
tracks := build(c, kept)
if len(tracks) < genreMixMinLen {
continue
}
kept = append(kept, c.Key)
for id := range sets[i] {
owned[id] = struct{}{}
}
out = append(out, builtPlaylist{
Name: c.Label + " mix",
Variant: "genre_mix",
Tracks: tracks,
})
}
return out
}
// specificFirst returns the candidates' indexes in rank order, except that
// within each tier the broad genres come after the specific ones.
func specificFirst(cands []genreCandidate, sets []map[pgtype.UUID]struct{}) []int {
broad := make([]bool, len(cands))
for i := range cands {
broad[i] = isBroad(i, sets)
}
order := make([]int, 0, len(cands))
for start := 0; start < len(cands); {
end := start
for end < len(cands) && cands[end].Tier == cands[start].Tier {
end++
}
for _, wantBroad := range []bool{false, true} {
for i := start; i < end; i++ {
if broad[i] == wantBroad {
order = append(order, i)
}
}
}
start = end
}
return order
}
// isBroad reports whether at least genreMixBroadShare of genre g's songs
// also carry a clearly narrower candidate genre.
func isBroad(g int, sets []map[pgtype.UUID]struct{}) bool {
if len(sets[g]) == 0 {
return false
}
var narrower []map[pgtype.UUID]struct{}
for h, set := range sets {
if h == g || float64(len(set)) > genreMixNarrowerSize*float64(len(sets[g])) {
continue
}
if 2*sharedCount(set, sets[g]) >= len(set) {
narrower = append(narrower, set)
}
}
covered := 0
for id := range sets[g] {
for _, set := range narrower {
if _, ok := set[id]; ok {
covered++
break
}
}
}
return float64(covered) >= genreMixBroadShare*float64(len(sets[g]))
}
// mostlyNew reports whether at least genreMixMinNewShare of set's songs are
// not yet owned by a kept genre.
func mostlyNew(set, owned map[pgtype.UUID]struct{}) bool {
return float64(len(set)-sharedCount(set, owned)) >= genreMixMinNewShare*float64(len(set))
}
// sharedCount counts the songs in both a and b.
func sharedCount(a, b map[pgtype.UUID]struct{}) int {
if len(b) < len(a) {
a, b = b, a
}
n := 0
for id := range a {
if _, ok := b[id]; ok {
n++
}
}
return n
}
// composeGenreMix interleaves familiar and unheard tracks 3 to 5
// (familiar, familiar, unheard, familiar, unheard, …),
// continuing with whichever side remains once the other runs out, then
// applies the per-album / per-artist caps and truncates to n. A genre too
// narrow for the caps is topped up from the uncapped pool, as the discovery
// mixes are. Familiar tracks are stamped pick_kind 'taste' and unheard ones
// 'taste_unheard', so skips can be attributed to each half.
func composeGenreMix(tracks []genreTrack, n int) []rankedCandidate {
var familiar, unheard []discoverTrack
for _, t := range tracks {
d := t.discoverTrack
if t.Familiar {
d.PickKind = pickKindTaste
familiar = append(familiar, d)
} else {
d.PickKind = pickKindTasteUnheard
unheard = append(unheard, d)
}
}
pool := make([]discoverTrack, 0, len(familiar)+len(unheard))
fi, ui := 0, 0
for fi < len(familiar) || ui < len(unheard) {
takeFamiliar := fi < len(familiar) &&
(ui >= len(unheard) || fi*genreMixParts < genreMixFamiliarParts*(fi+ui+1))
if takeFamiliar {
pool = append(pool, familiar[fi])
fi++
} else {
pool = append(pool, unheard[ui])
ui++
}
}
capped := topUpFromRaw(capByAlbumAndArtist(pool), pool, n)
if len(capped) > n {
capped = capped[:n]
}
out := make([]rankedCandidate, len(capped))
for i, t := range capped {
out[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind}
}
return out
}