feat: genre mixes — a daily playlist per genre you listen to, on a "Your genres" Home row (#6904)
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
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
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>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user