Files
minstrel/internal/playlists/genre_mixes.go
T
bvandeusenandClaude Opus 5.5 dbaa8b7d57
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 25s
release / integration (push) Failing after 4m38s
release / android (push) Successful in 4m3s
release / Build signed APK (push) Successful in 4m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: genre mixes — a daily playlist per genre you listen to, on a "Your genres" Home row (#6904)
A new system playlist kind, genre_mix: up to 10 "{Genre} mix" playlists per
user, rebuilt daily with the other system mixes.

Which genres: library genres with at least 25 playable tracks across 3
artists, ranked in tiers — taste-profile genres first (matched
case-insensitively, so ID3 "Post-Punk" and folksonomy "post-punk" are one),
then genres the user has played or liked, then the rest of the library, which
only tops a short shelf up to 4. A genre whose artist set is a near
duplicate of one already picked (Jaccard >= 0.5) is skipped, so "Lo-Fi" and
"Lo-Fi Hip Hop" don't both appear but "Alternative Rock" survives "Rock". A
genre that can't fill 15 tracks gives way to the next.

Each mix is 50 tracks: 3 in 5 the user already knows (played through or
liked), the rest unheard in that genre, interleaved, with the usual
per-album/per-artist caps. Tracks skipped twice and more often than played
are left out. The order changes daily and holds within a day. Picks are
stamped taste / taste_unheard, and genre_mix plays count toward rotation and
the recommendation metrics.

Migration 0102 adds genre_mix to the playlists variant constraints.

Web and Android each get a "Your genres" row in name order, hidden until a
mix exists. Kinds with several playlists per user now play by playlist id
wherever the client used to test seed_artist_id (web card, detail page
self-heal), keyed on the server's refreshable flag; on Android genre_mix
joins songs_like_artist as not refreshable. The web detail page's
"mix was rebuilt" reload now finds a Songs-like or genre mix by name instead
of calling the by-kind shuffle, which those kinds don't have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:30:41 -05:00

203 lines
6.4 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 set of genres is stable from day to day, like a list of channels; the
// tracks inside each mix change daily.
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
// genreMixMaxOverlap skips a candidate genre whose artist set is a near
// duplicate of one already picked: shared artists / all artists of the
// pair (Jaccard) at or above this. Symmetric on purpose — a broad
// "Rock" does not knock out every rock subgenre, but "Lo-Fi" and
// "Lo-Fi Hip Hop" with the same artists collapse to one mix.
genreMixMaxOverlap = 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
ArtistIDs []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, ArtistIDs: r.ArtistIds}
}
build := func(c genreCandidate) []rankedCandidate {
trackRows, terr := q.ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
UserID: userID,
GenreKey: c.Key,
DateSeed: dateStr,
})
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 in rank order and keeps the ones
// that make a real mix, skipping a genre that mostly repeats the artists of
// one already kept, and taking tier-3 genres only while the shelf is below
// genreMixMinShelf.
func selectGenreMixes(cands []genreCandidate, build func(genreCandidate) []rankedCandidate) []builtPlaylist {
var kept []genreCandidate
var out []builtPlaylist
for _, c := range cands {
if len(out) >= genreMixCount {
break
}
if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf {
break
}
if overlapsKept(c, kept) {
continue
}
tracks := build(c)
if len(tracks) < genreMixMinLen {
continue
}
kept = append(kept, c)
out = append(out, builtPlaylist{
Name: c.Label + " mix",
Variant: "genre_mix",
Tracks: tracks,
})
}
return out
}
// overlapsKept reports whether c's artists are a near duplicate (Jaccard at
// or above genreMixMaxOverlap) of any single kept genre's.
func overlapsKept(c genreCandidate, kept []genreCandidate) bool {
if len(c.ArtistIDs) == 0 {
return false
}
for _, k := range kept {
in := make(map[pgtype.UUID]struct{}, len(k.ArtistIDs))
for _, a := range k.ArtistIDs {
in[a] = struct{}{}
}
shared := 0
for _, a := range c.ArtistIDs {
if _, ok := in[a]; ok {
shared++
}
}
union := len(in) + len(c.ArtistIDs) - shared
if float64(shared) >= genreMixMaxOverlap*float64(union) {
return true
}
}
return false
}
// 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
}