feat(home): Songs-like → dedicated row + wider spread (#1491)
Promote the best-performing surface ("Songs like {artist}", ~8% skip /
~86% completion) out of the shared Playlists carousel into its own Home
row on both Android and web, and widen the daily build from 3 to 6 mixes
so the dedicated row shows a wider spread.
Server (internal/playlists):
- PickSeedArtists candidate pool 5 → 12; pickSeedArtistsForDay now takes
songsLikeSeedCount (6) instead of a hardcoded 3. Graceful degradation
and daily rotation preserved.
Android (HomeScreen.kt):
- New songsLikeSection + buildSongsLikeRow; PlaylistsRow takes a title so
it renders both the "Playlists" and "Songs like…" rows. buildOnlineRow
/ orderedRealPlaylists no longer reserve the 3 songs-like slots.
Offline shows cached mixes (available-first), hides the row when none.
Web (+page.svelte):
- Dedicated "Songs like…" row from songsLikeRow; dropped the 3-slot cap
and removed songs-like from the Playlists carousel.
Tests: seed_selection_test.go, BuildPlaylistsRowTest.kt, page.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -98,8 +99,8 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
|
||||
|
||||
// pickDailySeeds shuffles the candidate pool daily-deterministically
|
||||
// and takes up to n — For-You uses n=forYouSeedCount for its
|
||||
// multi-seed blend (#1269), Songs-like uses n=3 via the
|
||||
// pickSeedArtistsForDay wrapper. Verifies determinism within a day,
|
||||
// multi-seed blend (#1269), Songs-like uses n=songsLikeSeedCount via
|
||||
// the pickSeedArtistsForDay wrapper. Verifies determinism within a day,
|
||||
// variation across days, and graceful degradation on small pools.
|
||||
|
||||
func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) {
|
||||
@@ -158,24 +159,38 @@ func TestPickDailySeeds_EmptyPool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// pickSeedArtistsForDay takes the user's top-5 candidate artists and
|
||||
// returns 3 of them via daily-deterministic shuffle. Verifies the
|
||||
// picker is deterministic within a day, varies across days, and
|
||||
// degrades gracefully when fewer than 3 or 5 candidates exist.
|
||||
// pickSeedArtistsForDay takes the user's top-12 candidate artists and
|
||||
// returns songsLikeSeedCount of them via daily-deterministic shuffle.
|
||||
// Verifies the picker is deterministic within a day, varies across days,
|
||||
// and degrades gracefully when fewer than songsLikeSeedCount candidates
|
||||
// exist. bigSeedPool is a pool comfortably larger than songsLikeSeedCount
|
||||
// so the "returns exactly the count" cases have room.
|
||||
func bigSeedPool(n int) []pgtype.UUID {
|
||||
pool := make([]pgtype.UUID, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
pool = append(pool, pgtype.UUID{Bytes: [16]byte{byte(10 * (i + 1))}, Valid: true})
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// seedSetKey sorts the picked seeds' lead bytes into a stable string so
|
||||
// two picks with the same members (any order) compare equal.
|
||||
func seedSetKey(seeds []pgtype.UUID) string {
|
||||
bs := make([]byte, 0, len(seeds))
|
||||
for _, s := range seeds {
|
||||
bs = append(bs, s.Bytes[0])
|
||||
}
|
||||
sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] })
|
||||
return string(bs)
|
||||
}
|
||||
|
||||
func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
|
||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||
pool := []pgtype.UUID{
|
||||
{Bytes: [16]byte{10}, Valid: true},
|
||||
{Bytes: [16]byte{20}, Valid: true},
|
||||
{Bytes: [16]byte{30}, Valid: true},
|
||||
{Bytes: [16]byte{40}, Valid: true},
|
||||
{Bytes: [16]byte{50}, Valid: true},
|
||||
}
|
||||
pool := bigSeedPool(10)
|
||||
a := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
||||
b := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
||||
if len(a) != 3 || len(b) != 3 {
|
||||
t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b))
|
||||
if len(a) != songsLikeSeedCount || len(b) != songsLikeSeedCount {
|
||||
t.Fatalf("expected %d seeds; got %d / %d", songsLikeSeedCount, len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
@@ -186,40 +201,23 @@ func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
|
||||
|
||||
func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) {
|
||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||
pool := []pgtype.UUID{
|
||||
{Bytes: [16]byte{10}, Valid: true},
|
||||
{Bytes: [16]byte{20}, Valid: true},
|
||||
{Bytes: [16]byte{30}, Valid: true},
|
||||
{Bytes: [16]byte{40}, Valid: true},
|
||||
{Bytes: [16]byte{50}, Valid: true},
|
||||
}
|
||||
// Collect the trio (as a sorted byte tuple) across many dates.
|
||||
// With C(5,3) = 10 possible trios, 30 dates should yield >=2 distinct sets.
|
||||
seen := map[[3]byte]bool{}
|
||||
pool := bigSeedPool(10)
|
||||
// Collect the seed set (order-independent) across many dates. With
|
||||
// C(10, songsLikeSeedCount) combinations, 30 dates yield >=2 distinct sets.
|
||||
seen := map[string]bool{}
|
||||
for i := 1; i <= 30; i++ {
|
||||
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 seeds; got %d", len(got))
|
||||
if len(got) != songsLikeSeedCount {
|
||||
t.Fatalf("expected %d seeds; got %d", songsLikeSeedCount, len(got))
|
||||
}
|
||||
// Sort the three byte values for set-equivalence comparison.
|
||||
v := [3]byte{got[0].Bytes[0], got[1].Bytes[0], got[2].Bytes[0]}
|
||||
if v[0] > v[1] {
|
||||
v[0], v[1] = v[1], v[0]
|
||||
}
|
||||
if v[1] > v[2] {
|
||||
v[1], v[2] = v[2], v[1]
|
||||
}
|
||||
if v[0] > v[1] {
|
||||
v[0], v[1] = v[1], v[0]
|
||||
}
|
||||
seen[v] = true
|
||||
seen[seedSetKey(got)] = true
|
||||
}
|
||||
if len(seen) < 2 {
|
||||
t.Errorf("expected >=2 distinct seed trios across 30 days; got %d", len(seen))
|
||||
t.Errorf("expected >=2 distinct seed sets across 30 days; got %d", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickSeedArtistsForDay_FewerThanFive(t *testing.T) {
|
||||
func TestPickSeedArtistsForDay_FewerThanCount(t *testing.T) {
|
||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||
pool := []pgtype.UUID{
|
||||
{Bytes: [16]byte{10}, Valid: true},
|
||||
|
||||
@@ -35,7 +35,7 @@ type seedArtistRow struct {
|
||||
}
|
||||
|
||||
// pickSeedArtistsFromRows projects sqlc rows into the seed list. The
|
||||
// SQL already orders by score DESC + artist_id and LIMIT 3, so this is
|
||||
// SQL already orders by score DESC + artist_id and LIMIT 12, so this is
|
||||
// just a column projection — but pulling it into a function keeps the
|
||||
// call-site readable and makes the post-fetch path testable without
|
||||
// a database.
|
||||
@@ -110,10 +110,20 @@ func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n in
|
||||
return shuffled[:n]
|
||||
}
|
||||
|
||||
// pickSeedArtistsForDay picks up to 3 seed artists for the Songs-like
|
||||
// mixes; For-You uses pickDailySeeds directly with forYouSeedCount.
|
||||
// songsLikeSeedCount is how many "Songs like {artist}" mixes to build
|
||||
// each day (#1491). Bumped from 3 when Songs-like was promoted to its
|
||||
// own dedicated Home row — the best-performing surface (low skip / high
|
||||
// completion) was buried as 3 tiles in the shared Playlists carousel, so
|
||||
// the wider row now shows a wider spread. Drawn from PickSeedArtists'
|
||||
// deeper top-12 pool via a daily-deterministic shuffle so the set still
|
||||
// rotates day to day rather than pinning the same six artists.
|
||||
const songsLikeSeedCount = 6
|
||||
|
||||
// pickSeedArtistsForDay picks up to songsLikeSeedCount seed artists for
|
||||
// the Songs-like mixes; For-You uses pickDailySeeds directly with
|
||||
// forYouSeedCount.
|
||||
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
|
||||
return pickDailySeeds(pool, userID, dateStr, 3)
|
||||
return pickDailySeeds(pool, userID, dateStr, songsLikeSeedCount)
|
||||
}
|
||||
|
||||
// rankedCandidate is a (track_id, score) pair used during in-memory
|
||||
@@ -589,8 +599,8 @@ func produceForYou(
|
||||
return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil
|
||||
}
|
||||
|
||||
// produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed
|
||||
// artists rotate daily-deterministically; the seed query falls back
|
||||
// produceSeedMixes: up to songsLikeSeedCount "Songs like {artist}"
|
||||
// mixes. Seed artists rotate daily-deterministically; the seed query falls back
|
||||
// through widening engagement windows (#1255) and every returned row
|
||||
// shares the winning tier, stamped onto the built tracks as their
|
||||
// pick_kind. The base seed-artist query failing is fatal; per-artist
|
||||
@@ -685,7 +695,7 @@ func produceDiscover(
|
||||
}
|
||||
|
||||
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
|
||||
// up to 3 Songs-like-{seed} mixes). Atomic-replace inside one tx;
|
||||
// up to songsLikeSeedCount Songs-like-{seed} mixes). Atomic-replace inside one tx;
|
||||
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
|
||||
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user