release / web (push) Successful in 1m40s
release / go (push) Successful in 2m5s
release / govulncheck (push) Successful in 19s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 4m40s
release / Build signed APK (push) Successful in 5m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
Within a genre both halves of the mix were a uniform daily draw: a song played forty times was no likelier than one played once, and the unheard 40% ignored who the artist was. ListGenreMixTracks now draws each half by a date-seeded weighted lottery (Efraimidis-Spirakis: smallest -ln(u)/weight wins), so the mix still changes daily and holds within a day. - Familiar: 1 + ln(1 + plays) + 1 if liked, times the share of plays that weren't skips. - Unheard: 1 + ln(1 + the artist's plays + 2 x its liked tracks) + 2 x the artist's best similarity to the user's own artists, times the artist's non-skip share. An unknown artist weighs 1, so new artists still come up. Which genres are offered, the exclusions and the familiar/unheard blend are unchanged. DB tests check the lean averaged over ten days, and that a stranger's tracks stay in the pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
10 KiB
Go
305 lines
10 KiB
Go
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.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)
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
}
|
||
}
|