Files
minstrel/internal/playlists/genre_mixes_db_test.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

198 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}