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
+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)
}
}