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:
@@ -350,7 +350,7 @@ SELECT c.artist_id,
|
||||
FROM chosen c
|
||||
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
||||
ORDER BY score DESC, c.artist_id
|
||||
LIMIT 5
|
||||
LIMIT 12
|
||||
`
|
||||
|
||||
type PickSeedArtistsRow struct {
|
||||
@@ -359,7 +359,7 @@ type PickSeedArtistsRow struct {
|
||||
Tier int32
|
||||
}
|
||||
|
||||
// Top-5 most-engaged distinct artist candidates, tiered so the
|
||||
// Top-12 most-engaged distinct artist candidates, tiered so the
|
||||
// "Songs like X" mixes never silently vanish (#1255): the old hard
|
||||
// 7-day window emptied the seed pool after a quiet week, and the
|
||||
// daily atomic-replace build then deleted every existing mix until
|
||||
@@ -375,8 +375,9 @@ type PickSeedArtistsRow struct {
|
||||
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
||||
// can compare mixes seeded from fresh vs stale engagement.
|
||||
// Score = unskipped-play count + 5 if user has liked the artist. The
|
||||
// Go-side picker (pickSeedArtistsForDay) shuffles the 5
|
||||
// daily-deterministically and takes 3 so the mix set rotates.
|
||||
// Go-side picker (pickSeedArtistsForDay) shuffles the 12
|
||||
// daily-deterministically and takes songsLikeSeedCount (6) so the mix
|
||||
// set both rotates day-to-day and fills the dedicated Home row (#1491).
|
||||
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
||||
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -47,7 +47,7 @@ UPDATE system_playlist_runs
|
||||
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
|
||||
|
||||
-- name: PickSeedArtists :many
|
||||
-- Top-5 most-engaged distinct artist candidates, tiered so the
|
||||
-- Top-12 most-engaged distinct artist candidates, tiered so the
|
||||
-- "Songs like X" mixes never silently vanish (#1255): the old hard
|
||||
-- 7-day window emptied the seed pool after a quiet week, and the
|
||||
-- daily atomic-replace build then deleted every existing mix until
|
||||
@@ -61,8 +61,9 @@ UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
|
||||
-- rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
||||
-- can compare mixes seeded from fresh vs stale engagement.
|
||||
-- Score = unskipped-play count + 5 if user has liked the artist. The
|
||||
-- Go-side picker (pickSeedArtistsForDay) shuffles the 5
|
||||
-- daily-deterministically and takes 3 so the mix set rotates.
|
||||
-- Go-side picker (pickSeedArtistsForDay) shuffles the 12
|
||||
-- daily-deterministically and takes songsLikeSeedCount (6) so the mix
|
||||
-- set both rotates day-to-day and fills the dedicated Home row (#1491).
|
||||
WITH liked AS (
|
||||
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||
),
|
||||
@@ -123,7 +124,7 @@ SELECT c.artist_id,
|
||||
FROM chosen c
|
||||
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
||||
ORDER BY score DESC, c.artist_id
|
||||
LIMIT 5;
|
||||
LIMIT 12;
|
||||
|
||||
-- name: PickTopPlayedTracksForUser :many
|
||||
-- For-You candidate seeds, tiered so For-You never silently vanishes:
|
||||
|
||||
@@ -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