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

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:
2026-11-05 22:30:41 -05:00
co-authored by Claude Opus 5.5
parent 477f453100
commit dbaa8b7d57
21 changed files with 1239 additions and 23 deletions
+202
View File
@@ -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
}
+197
View File
@@ -0,0 +1,197 @@
package playlists_test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
// seedGenre creates artists × perArtist tracks tagged genre, each artist on
// its own album, and returns the tracks.
func seedGenre(t *testing.T, pool *pgxpool.Pool, genre string, artists, perArtist int) []dbq.Track {
t.Helper()
var out []dbq.Track
for a := 0; a < artists; a++ {
for k := 0; k < perArtist; k++ {
tr := seedTrack(t, pool, fmt.Sprintf("%s %d-%d", genre, a, k), fmt.Sprintf("%s Artist %d", genre, a))
setGenre(t, pool, tr.ID, genre)
out = append(out, tr)
}
}
return out
}
// TestListGenreMixCandidates_TiersAndSizeFloor: a taste-profile genre is
// tier 1 (matched case-insensitively), a genre the user has only played is
// tier 2, an untouched one is tier 3, and a genre too small for a mix is not
// offered at all.
func TestListGenreMixCandidates_TiersAndSizeFloor(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "gmixcand")
now := time.Now().UTC()
seedGenre(t, pool, "Shoegaze", 6, 5)
kraut := seedGenre(t, pool, "Krautrock", 6, 5)
seedGenre(t, pool, "Dub", 6, 5)
seedGenre(t, pool, "Zydeco", 2, 5) // 10 tracks, 2 artists: below the floor
seedTasteTag(t, pool, u.ID, "shoegaze", 4)
seedPlayEvent(t, pool, u.ID, kraut[0].ID, now.Add(-time.Hour), false)
rows, err := dbq.New(pool).ListGenreMixCandidates(ctx, u.ID)
if err != nil {
t.Fatalf("list candidates: %v", err)
}
want := map[string]int32{"Shoegaze": 1, "Krautrock": 2, "Dub": 3}
tier := map[string]int32{}
order := []string{}
for _, r := range rows {
tier[r.Label] = r.Tier
if _, mine := want[r.Label]; mine {
order = append(order, r.Label)
}
if r.Label == "Shoegaze" && len(r.ArtistIds) != 6 {
t.Errorf("Shoegaze artist_ids = %d, want 6", len(r.ArtistIds))
}
}
for label, w := range want {
if got, ok := tier[label]; !ok || got != w {
t.Errorf("%s tier = %d (present %v), want %d", label, got, ok, w)
}
}
if _, ok := tier["Zydeco"]; ok {
t.Error("Zydeco (10 tracks, 2 artists) offered as a genre mix")
}
if strings.Join(order, ",") != "Shoegaze,Krautrock,Dub" {
t.Errorf("order = %v, want tier order Shoegaze, Krautrock, Dub", order)
}
}
// TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks: played and
// liked tracks come back familiar, the rest unheard; a track skipped more
// often than played through is left out, while a favourite with stray skips
// stays; tracks of other genres never appear.
func TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "gmixtracks")
now := time.Now().UTC()
shoegaze := seedGenre(t, pool, "Shoegaze", 6, 5)
other := seedGenre(t, pool, "Dub", 3, 2)
played, liked, rejected, favourite := shoegaze[0], shoegaze[1], shoegaze[2], shoegaze[3]
seedPlayEvent(t, pool, u.ID, played.ID, now.Add(-time.Hour), false)
if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, liked.ID); err != nil {
t.Fatalf("like: %v", err)
}
for i := 0; i < 2; i++ {
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-time.Duration(i+2)*time.Hour), true)
}
for i := 0; i < 5; i++ {
seedPlayEvent(t, pool, u.ID, favourite.ID, now.Add(-time.Duration(i+10)*time.Hour), false)
}
for i := 0; i < 2; i++ {
seedPlayEvent(t, pool, u.ID, favourite.ID, now.Add(-time.Duration(i+20)*time.Hour), true)
}
rows, err := dbq.New(pool).ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
UserID: u.ID, DateSeed: "2026-11-05", GenreKey: "shoegaze",
})
if err != nil {
t.Fatalf("list tracks: %v", err)
}
familiar := map[pgtype.UUID]bool{}
for _, r := range rows {
familiar[r.ID] = r.Familiar
}
if len(rows) != len(shoegaze)-1 {
t.Errorf("rows = %d, want %d (every Shoegaze track but the rejected one)", len(rows), len(shoegaze)-1)
}
for _, tr := range other {
if _, in := familiar[tr.ID]; in {
t.Fatalf("a Dub track appeared in the Shoegaze pool")
}
}
if _, in := familiar[rejected.ID]; in {
t.Error("a track skipped twice and never played through was offered")
}
for name, tr := range map[string]dbq.Track{"played": played, "liked": liked, "favourite": favourite} {
if f, in := familiar[tr.ID]; !in || !f {
t.Errorf("%s track: present %v familiar %v, want present and familiar", name, in, f)
}
}
if f := familiar[shoegaze[4].ID]; f {
t.Error("an untouched track came back familiar")
}
}
// TestBuildSystemPlaylists_GenreMixes: a full build gives the user a mix for
// the genre they listen to, holding only that genre's tracks, with both
// halves of the blend stamped.
func TestBuildSystemPlaylists_GenreMixes(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "gmixbuild")
now := time.Now().UTC()
shoegaze := seedGenre(t, pool, "Shoegaze", 8, 5)
seedGenre(t, pool, "Dub", 6, 5)
seedTasteTag(t, pool, u.ID, "Shoegaze", 5)
for i, tr := range shoegaze[:12] {
seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(i+1)*time.Hour), false)
}
if err := playlists.BuildSystemPlaylists(ctx, pool, discardLogger(), u.ID, now, t.TempDir()); err != nil {
t.Fatalf("build: %v", err)
}
var plID pgtype.UUID
if err := pool.QueryRow(ctx, `
SELECT id FROM playlists
WHERE user_id = $1 AND system_variant = 'genre_mix' AND name = 'Shoegaze mix'`,
u.ID).Scan(&plID); err != nil {
t.Fatalf("no Shoegaze mix built: %v", err)
}
inGenre := map[pgtype.UUID]bool{}
for _, tr := range shoegaze {
inGenre[tr.ID] = true
}
rows, err := pool.Query(ctx, `SELECT track_id, pick_kind FROM playlist_tracks WHERE playlist_id = $1`, plID)
if err != nil {
t.Fatalf("list mix tracks: %v", err)
}
defer rows.Close()
kinds := map[string]int{}
n := 0
for rows.Next() {
var id pgtype.UUID
var kind *string
if err := rows.Scan(&id, &kind); err != nil {
t.Fatalf("scan: %v", err)
}
n++
if !inGenre[id] {
t.Errorf("track %s in the Shoegaze mix is not a Shoegaze track", uuidString(id))
}
if kind != nil {
kinds[*kind]++
}
}
if n < 15 {
t.Fatalf("Shoegaze mix holds %d tracks, want at least 15", n)
}
if kinds["taste"] == 0 || kinds["taste_unheard"] == 0 {
t.Errorf("pick kinds = %v, want both taste and taste_unheard", kinds)
}
}
+198
View File
@@ -0,0 +1,198 @@
package playlists
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
// genreTracks builds n tracks, each on its own album and artist (so the
// caps never bind), numbered from start.
func genreTracks(start, n int, familiar bool) []genreTrack {
out := make([]genreTrack, n)
for i := range out {
id := uuidN(start + i)
out[i] = genreTrack{
discoverTrack: discoverTrack{ID: id, AlbumID: id, ArtistID: id},
Familiar: familiar,
}
}
return out
}
func kindsOf(tracks []rankedCandidate) string {
s := make([]byte, len(tracks))
for i, t := range tracks {
switch t.PickKind {
case pickKindTaste:
s[i] = 'F'
case pickKindTasteUnheard:
s[i] = 'U'
default:
s[i] = '?'
}
}
return string(s)
}
func TestComposeGenreMix_InterleavesThreeFamiliarToTwoUnheard(t *testing.T) {
tracks := append(genreTracks(1, 20, true), genreTracks(100, 20, false)...)
got := composeGenreMix(tracks, 10)
if want := "FFUFUFFUFU"; kindsOf(got) != want {
t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want)
}
}
func TestComposeGenreMix_ShortSideIsFilledFromTheOther(t *testing.T) {
// Two familiar tracks only: the rest of the mix is unheard, not short.
tracks := append(genreTracks(1, 2, true), genreTracks(100, 20, false)...)
got := composeGenreMix(tracks, 10)
if len(got) != 10 {
t.Fatalf("len = %d, want 10", len(got))
}
if want := "FFUUUUUUUU"; kindsOf(got) != want {
t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want)
}
// No unheard tracks at all: an all-familiar mix.
got = composeGenreMix(genreTracks(1, 12, true), 10)
if want := "FFFFFFFFFF"; kindsOf(got) != want {
t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want)
}
}
func TestComposeGenreMix_CapsOneArtistThenTopsUpANarrowGenre(t *testing.T) {
// One artist, one album, 8 tracks, plus 4 single-track artists. The cap
// keeps 2 of the big album; the top-up refills from it so the mix still
// reaches 8.
big := genreTracks(1, 8, false)
for i := range big {
big[i].AlbumID = uuidN(500)
big[i].ArtistID = uuidN(500)
}
tracks := append(big, genreTracks(100, 4, false)...)
got := composeGenreMix(tracks, 8)
if len(got) != 8 {
t.Fatalf("len = %d, want 8", len(got))
}
// The first six are the capped pool: 2 from the big album, then the 4
// singles — the big album does not lead the mix wholesale.
bigID := map[pgtype.UUID]bool{}
for _, b := range big {
bigID[b.ID] = true
}
inHead := 0
for _, c := range got[:6] {
if bigID[c.TrackID] {
inHead++
}
}
if inHead != 2 {
t.Fatalf("big-album tracks in the capped head = %d, want 2", inHead)
}
}
func artists(ns ...int) []pgtype.UUID {
out := make([]pgtype.UUID, len(ns))
for i, n := range ns {
out[i] = uuidN(n)
}
return out
}
func TestOverlapsKept_NearDuplicatesCollapseButSubgenresOfABroadGenreStay(t *testing.T) {
rock := genreCandidate{Label: "Rock", ArtistIDs: artists(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)}
altRock := genreCandidate{Label: "Alternative Rock", ArtistIDs: artists(1, 2, 3)}
lofi := genreCandidate{Label: "Lo-Fi", ArtistIDs: artists(20, 21, 22, 23)}
lofiHipHop := genreCandidate{Label: "Lo-Fi Hip Hop", ArtistIDs: artists(20, 21, 22, 24)}
if overlapsKept(altRock, []genreCandidate{rock}) {
t.Error("Alternative Rock (3 of Rock's 10 artists) collapsed into Rock; a subgenre of a broad genre should stay")
}
if !overlapsKept(lofiHipHop, []genreCandidate{rock, lofi}) {
t.Error("Lo-Fi Hip Hop (3 of 5 artists shared with Lo-Fi) was kept; near duplicates should collapse")
}
if overlapsKept(lofi, nil) {
t.Error("nothing kept yet, but overlap reported")
}
}
// buildN returns a builder that gives every genre n tracks, except those
// listed in short, which get too few to make a mix.
func buildN(n int, short ...string) func(genreCandidate) []rankedCandidate {
thin := map[string]bool{}
for _, s := range short {
thin[s] = true
}
return func(c genreCandidate) []rankedCandidate {
if thin[c.Label] {
return make([]rankedCandidate, genreMixMinLen-1)
}
return make([]rankedCandidate, n)
}
}
func labels(built []builtPlaylist) []string {
out := make([]string, len(built))
for i, b := range built {
out[i] = b.Name
}
return out
}
func distinctGenre(label string, tier int32, n int) genreCandidate {
return genreCandidate{Key: label, Label: label, Tier: tier, ArtistIDs: artists(n)}
}
func TestSelectGenreMixes_TakesTheUsersGenresAndStopsAtTheCount(t *testing.T) {
var cands []genreCandidate
for i := 0; i < 14; i++ {
cands = append(cands, distinctGenre(string(rune('A'+i)), 1, i+1))
}
got := selectGenreMixes(cands, buildN(30))
if len(got) != genreMixCount {
t.Fatalf("built %d mixes, want %d", len(got), genreMixCount)
}
if got[0].Name != "A mix" || got[0].Variant != "genre_mix" {
t.Fatalf("first mix = %q (%s), want \"A mix\" (genre_mix)", got[0].Name, got[0].Variant)
}
}
func TestSelectGenreMixes_ThinGenreGivesWayToTheNext(t *testing.T) {
cands := []genreCandidate{
distinctGenre("Jazz", 1, 1),
distinctGenre("Dub", 1, 2),
distinctGenre("Soul", 2, 3),
}
got := labels(selectGenreMixes(cands, buildN(30, "Dub")))
if len(got) != 2 || got[0] != "Jazz mix" || got[1] != "Soul mix" {
t.Fatalf("mixes = %v, want [Jazz mix Soul mix]", got)
}
}
func TestSelectGenreMixes_LibraryGenresOnlyFillAShortShelf(t *testing.T) {
// Two listened genres: tier 3 tops the shelf up to genreMixMinShelf.
cands := []genreCandidate{
distinctGenre("Jazz", 1, 1),
distinctGenre("Soul", 2, 2),
distinctGenre("Polka", 3, 3),
distinctGenre("Opera", 3, 4),
distinctGenre("Ska", 3, 5),
}
got := labels(selectGenreMixes(cands, buildN(30)))
if len(got) != genreMixMinShelf {
t.Fatalf("mixes = %v, want %d", got, genreMixMinShelf)
}
// Enough listened genres: no tier-3 genre at all.
var full []genreCandidate
for i := 0; i < genreMixMinShelf; i++ {
full = append(full, distinctGenre(string(rune('A'+i)), 1, i+1))
}
full = append(full, distinctGenre("Polka", 3, 99))
for _, name := range labels(selectGenreMixes(full, buildN(30))) {
if name == "Polka mix" {
t.Fatal("a library-only genre was added to a shelf the user's own genres already fill")
}
}
}
+3 -2
View File
@@ -531,8 +531,8 @@ type systemPlaylistKind struct {
// Discover, the discovery mixes) and so can be addressed by kind:
// the generic /system/{kind}/{refresh,shuffle} endpoints + the
// per-tile refresh affordance only apply to these. Non-singleton
// kinds (songs_like_artist → up to 3 per user) are played by
// playlist id and have no by-kind endpoint.
// kinds (songs_like_artist, genre_mix — several per user) are played
// by playlist id and have no by-kind endpoint.
Singleton bool
Produce systemPlaylistProducer
}
@@ -563,6 +563,7 @@ var systemPlaylistRegistry = func() []systemPlaylistKind {
{Key: "for_you", Singleton: true, Produce: produceForYou},
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
{Key: "discover", Singleton: true, Produce: produceDiscover},
{Key: "genre_mix", Singleton: false, Produce: produceGenreMixes},
}
// The five discovery mixes share one Produce closure factory keyed
// by a per-mix spec; spec list + factory live in system_mixes.go.
+1 -1
View File
@@ -114,7 +114,7 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
if !r.SeedArtistID.Valid {
t.Errorf("songs_like_artist row should have non-NULL seed_artist_id")
}
case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens":
case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens", "genre_mix":
// Discover and the discovery mixes are all seedless —
// no seed_artist_id required.
default: