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>
198 lines
6.3 KiB
Go
198 lines
6.3 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.
|
||
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)
|
||
}
|
||
}
|