Files
minstrel/internal/playlists/genre_mixes_db_test.go
T
bvandeusenandClaude Opus 5.5 dabe1e052d
release / go (push) Successful in 2m0s
release / web (push) Successful in 1m33s
release / govulncheck (push) Successful in 18s
release / integration (push) Successful in 5m11s
release / android (push) Successful in 4m14s
release / Build signed APK (push) Successful in 4m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: genre mixes pick specific genres that don't share songs (#6933)
The shelf had four mixes built largely from the same lo-fi beats: about
1,250 tracks carry Hip Hop, Lo-Fi, Instrumental, Downtempo and four more
tags at once, and the near-duplicate guard compared artists, not songs, so
each tag passed as its own channel.

Genre selection now works on songs:
- ListGenreMixCandidates returns each genre's track ids instead of its
  artist ids.
- A broad genre, one whose songs mostly also carry clearly narrower
  candidates (Rock over Pop Punk and Ska Punk), is considered after the
  specific genres of its tier.
- A genre is kept only when at least half its songs are not already in a
  kept genre.
- Each mix leaves out songs tagged with a genre kept before it
  (ListGenreMixTracks exclude_genres), so a song is in one mix at most.

The tiers, the minimum shelf and the minimum mix length are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:55:19 -05:00

339 lines
12 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. seedTrack mints a new artist on
// every call (an artist without an mbid has no conflict key), so each
// artist's first track creates it and the rest join that track's album.
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++ {
first := seedTrack(t, pool, fmt.Sprintf("%s %d-0", genre, a), fmt.Sprintf("%s Artist %d", genre, a))
setGenre(t, pool, first.ID, genre)
out = append(out, first)
for k := 1; k < perArtist; k++ {
tr := seedTrackForArtist(t, pool, fmt.Sprintf("%s %d-%d", genre, a, k), first.AlbumID, first.ArtistID)
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.TrackIds) != 30 {
t.Errorf("Shoegaze track_ids = %d, want 30", len(r.TrackIds))
}
}
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_LeavesOutSongsOfGenresAlreadyPicked: a track that
// also carries an excluded genre (matched case-insensitively) belongs to that
// genre's mix and is left out of this one.
func TestListGenreMixTracks_LeavesOutSongsOfGenresAlreadyPicked(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "gmixexcl")
punk := seedGenre(t, pool, "Ska Punk", 4, 5)
shared := punk[:5]
for _, tr := range shared {
setGenre(t, pool, tr.ID, "Ska Punk; Pop Punk")
}
rows, err := dbq.New(pool).ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
UserID: u.ID, DateSeed: "2026-11-07", GenreKey: "ska punk", ExcludeGenres: []string{"pop punk"},
})
if err != nil {
t.Fatalf("list tracks: %v", err)
}
if len(rows) != len(punk)-len(shared) {
t.Fatalf("rows = %d, want %d (the Ska Punk tracks not also Pop Punk)", len(rows), len(punk)-len(shared))
}
got := map[pgtype.UUID]bool{}
for _, r := range rows {
got[r.ID] = true
}
for _, tr := range shared {
if got[tr.ID] {
t.Fatal("a track also tagged Pop Punk was offered after Pop Punk was excluded")
}
}
}
// 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)
}
}
// genreDrawPositions runs ListGenreMixTracks for ten different days and
// returns, per track, the sum of its positions within its own half
// (familiar or unheard). The draw is random per day, so a lean shows up as
// an earlier average position across days, not in any single one.
func genreDrawPositions(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, genreKey string) map[pgtype.UUID]int {
t.Helper()
sum := map[pgtype.UUID]int{}
for day := 1; day <= 10; day++ {
rows, err := dbq.New(pool).ListGenreMixTracks(context.Background(), dbq.ListGenreMixTracksParams{
UserID: userID, DateSeed: fmt.Sprintf("2026-11-%02d", day), GenreKey: genreKey,
})
if err != nil {
t.Fatalf("list tracks: %v", err)
}
pos := map[bool]int{}
for _, r := range rows {
sum[r.ID] += pos[r.Familiar]
pos[r.Familiar]++
}
}
return sum
}
func meanPosition(sum map[pgtype.UUID]int, tracks []dbq.Track) float64 {
total := 0
for _, tr := range tracks {
total += sum[tr.ID]
}
return float64(total) / float64(len(tracks))
}
// TestListGenreMixTracks_FamiliarLeansTowardFavourites: among the tracks the
// user knows, ones played often and liked come up earlier than ones played
// once.
func TestListGenreMixTracks_FamiliarLeansTowardFavourites(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "gmixfav")
now := time.Now().UTC()
tracks := seedGenre(t, pool, "Krautrock", 4, 5)
favourites, onceOnly := tracks[:10], tracks[10:]
for i, tr := range favourites {
for k := 0; k < 5; k++ {
seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(i*10+k+1)*time.Hour), false)
}
if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, tr.ID); err != nil {
t.Fatalf("like: %v", err)
}
}
for i, tr := range onceOnly {
seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(200+i)*time.Hour), false)
}
sum := genreDrawPositions(t, pool, u.ID, "krautrock")
fav, once := meanPosition(sum, favourites), meanPosition(sum, onceOnly)
if fav >= once {
t.Errorf("favourites' summed position %.1f, played-once %.1f: want favourites earlier", fav, once)
}
}
// TestListGenreMixTracks_UnheardLeansTowardTheUsersArtists: among unheard
// tracks, an artist the user plays and likes comes up earlier than one they
// have never met, and so does an artist similar to theirs. The stranger
// still appears: the lean tilts the draw, it does not exclude.
func TestListGenreMixTracks_UnheardLeansTowardTheUsersArtists(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "gmixartist")
now := time.Now().UTC()
tracks := seedGenre(t, pool, "Shoegaze", 3, 12)
loved, similar, stranger := tracks[:12], tracks[12:24], tracks[24:]
for k := 0; k < 15; k++ {
seedPlayEvent(t, pool, u.ID, loved[0].ID, now.Add(-time.Duration(k+1)*time.Hour), false)
}
for _, tr := range loved[1:3] {
if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, tr.ID); err != nil {
t.Fatalf("like: %v", err)
}
}
if _, err := pool.Exec(ctx, `
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source)
VALUES ($1, $2, 0.9, 'listenbrainz')`, loved[0].ArtistID, similar[0].ArtistID); err != nil {
t.Fatalf("seed similarity: %v", err)
}
sum := genreDrawPositions(t, pool, u.ID, "shoegaze")
l, s, x := meanPosition(sum, loved[3:]), meanPosition(sum, similar), meanPosition(sum, stranger)
if l >= x {
t.Errorf("loved artist's unheard tracks at %.1f, stranger's at %.1f: want the loved artist earlier", l, x)
}
if s >= x {
t.Errorf("similar artist's tracks at %.1f, stranger's at %.1f: want the similar artist earlier", s, x)
}
for _, tr := range stranger {
if _, in := sum[tr.ID]; !in {
t.Fatalf("a stranger's track was left out of the pool; the lean must not exclude")
}
}
}