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:
@@ -623,10 +623,12 @@ private fun HomeSuccessContent(
|
||||
) {
|
||||
item {
|
||||
// Always rendered: real system/user playlists, with
|
||||
// placeholder cards filling the For You / Discover /
|
||||
// 3× Songs-like slots that haven't generated yet. When
|
||||
// offline, the cache-backed pool cards lead the row.
|
||||
// placeholder cards filling the For You / Discover slots
|
||||
// that haven't generated yet. When offline, the cache-backed
|
||||
// pool cards lead the row. Songs-like now lives in its own
|
||||
// dedicated row below (#1491), no longer inside this carousel.
|
||||
PlaylistsRow(
|
||||
title = "Playlists",
|
||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
@@ -634,6 +636,18 @@ private fun HomeSuccessContent(
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
// Songs-like is the best-performing surface (#1491) — promoted out
|
||||
// of the Playlists carousel into its own row so it shows a wider
|
||||
// spread of "Songs like {artist}" mixes. Hidden when there's
|
||||
// nothing to show (offline with none cached).
|
||||
songsLikeSection(
|
||||
playlists = sections.playlists,
|
||||
status = systemStatus,
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onPlayPool = onPlayPool,
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
youMightLikeSection(
|
||||
albums = sections.youMightLikeAlbums,
|
||||
artists = sections.youMightLikeArtists,
|
||||
@@ -657,6 +671,35 @@ private fun HomeSuccessContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
|
||||
* rendering with a distinct title; hidden entirely when there's nothing
|
||||
* to show (offline with no cached mixes). Online with none generated yet
|
||||
* still shows a few placeholders so the building / seed-needed state is
|
||||
* visible, matching the pre-promotion carousel behavior.
|
||||
*/
|
||||
private fun LazyListScope.songsLikeSection(
|
||||
playlists: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
val rowItems = buildSongsLikeRow(playlists, status, offline)
|
||||
if (rowItems.isEmpty()) return
|
||||
item {
|
||||
PlaylistsRow(
|
||||
title = "Songs like…",
|
||||
rowItems = rowItems,
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onPlayPool = onPlayPool,
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.recentlyAddedSection(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
@@ -926,13 +969,14 @@ private fun AlbumsRow(
|
||||
|
||||
@Composable
|
||||
private fun PlaylistsRow(
|
||||
title: String,
|
||||
rowItems: List<PlaylistRowItem>,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Playlists") {
|
||||
HorizontalScrollRow(title = title) {
|
||||
itemsIndexed(items = rowItems) { _, item ->
|
||||
when (item) {
|
||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||
@@ -983,11 +1027,12 @@ enum class OfflinePoolKind(val label: String) {
|
||||
/**
|
||||
* Builds the Home Playlists row.
|
||||
*
|
||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
||||
* Online: For You + Discover fixed slots (real card when generated,
|
||||
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||
* rediscover / new for you / on this day / first listens) when they exist —
|
||||
* no placeholders for these since they're conditional on library shape — then
|
||||
* user-owned playlists.
|
||||
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
||||
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||
*
|
||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||
* same real playlists in curated order but stably partitioned fully-cached
|
||||
@@ -1014,7 +1059,7 @@ internal fun buildPlaylistsRow(
|
||||
return out
|
||||
}
|
||||
|
||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
||||
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||
private fun buildOnlineRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
@@ -1026,12 +1071,7 @@ private fun buildOnlineRow(
|
||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||
out += songsLike.getOrNull(i)
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
@@ -1039,16 +1079,42 @@ private fun buildOnlineRow(
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||
*
|
||||
* Online: every generated mix as a real card; when none exist yet, a few
|
||||
* placeholders so the building / seed-needed state stays visible.
|
||||
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
||||
* an empty list — hiding the whole section — when nothing is cached.
|
||||
*/
|
||||
internal fun buildSongsLikeRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
): List<PlaylistRowItem> {
|
||||
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
||||
if (offline) {
|
||||
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||
return (available + greyed).map { PlaylistRowItem.Real(it) }
|
||||
}
|
||||
if (mixes.isNotEmpty()) return mixes.map { PlaylistRowItem.Real(it) }
|
||||
return List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
||||
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||
* only differs by dropping placeholders + partitioning available-first.
|
||||
* Songs-like is excluded here too — it renders in its own row via
|
||||
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
||||
*/
|
||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||
val out = mutableListOf<PlaylistRef>()
|
||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||
}
|
||||
@@ -1063,7 +1129,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||
else -> "pending"
|
||||
}
|
||||
|
||||
private const val SONGS_LIKE_SLOTS = 3
|
||||
// How many "Songs like…" placeholder cards the dedicated row shows while
|
||||
// the mixes haven't generated yet (building / seed-needed). Real mixes,
|
||||
// once generated, are shown in full and no longer capped by this (#1491).
|
||||
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
|
||||
|
||||
/**
|
||||
* The 5 system playlist kinds the server generates that aren't pinned
|
||||
|
||||
@@ -56,4 +56,51 @@ class BuildPlaylistsRowTest {
|
||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
|
||||
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
|
||||
id = id,
|
||||
userId = "u",
|
||||
name = "Songs like $id",
|
||||
systemVariant = "songs_like_artist",
|
||||
trackCount = 25,
|
||||
fullyCached = cached,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `songs-like no longer appears in the main playlists carousel`() {
|
||||
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
|
||||
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertTrue("a" !in reals) { "songs-like mix leaked into the Playlists row: $reals" }
|
||||
assertTrue("u1" in reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online songs-like row shows every generated mix uncapped`() {
|
||||
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
|
||||
val owned = (1..6).map { songsLike("a$it", cached = true) }
|
||||
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals((1..6).map { "a$it" }, reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online songs-like row shows placeholders when none generated`() {
|
||||
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||
assertTrue(row.isNotEmpty())
|
||||
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
|
||||
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
|
||||
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
// Fully-cached songs-like mix (not refreshable) is available; the
|
||||
// un-cached one greys and sorts after. No placeholders offline.
|
||||
assertEquals(listOf("full", "partial"), reals)
|
||||
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||
|
||||
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")).
|
||||
//
|
||||
|
||||
@@ -43,10 +43,11 @@
|
||||
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'discover') ?? null
|
||||
);
|
||||
|
||||
// Songs-like mixes get their own dedicated row (#1491) — no longer
|
||||
// capped to 3 carousel slots, so take all the server generated.
|
||||
const songsLikePlaylists = $derived(
|
||||
(systemPlaylistsQ.data?.owned ?? [])
|
||||
.filter((p) => p.system_variant === 'songs_like_artist')
|
||||
.slice(0, 3)
|
||||
);
|
||||
|
||||
// Secondary system kinds the server generates that don't get pinned
|
||||
@@ -108,14 +109,7 @@
|
||||
out.push({ kind: 'placeholder', label: 'Discover', variant: placeholderVariant('discover') });
|
||||
}
|
||||
|
||||
// Slots 3-5: Songs-like (real first, padded with placeholders).
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (songsLikePlaylists[i]) {
|
||||
out.push({ kind: 'real', playlist: songsLikePlaylists[i] });
|
||||
} else {
|
||||
out.push({ kind: 'placeholder', label: 'Songs like…', variant: placeholderVariant('songs-like') });
|
||||
}
|
||||
}
|
||||
// Songs-like moved to its own dedicated row below (#1491).
|
||||
|
||||
// Secondary system kinds in server-registry order, when generated.
|
||||
for (const p of secondarySystemPlaylists) {
|
||||
@@ -129,6 +123,25 @@
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
// Dedicated Songs-like row (#1491): every generated "Songs like {artist}"
|
||||
// mix — the best-performing surface, promoted out of the Playlists
|
||||
// carousel to show a wider spread. Shows a few placeholders while the
|
||||
// mixes haven't generated yet (building / seed-needed).
|
||||
const SONGS_LIKE_PLACEHOLDER_SLOTS = 3;
|
||||
const songsLikeRow = $derived.by((): PlaylistRowItem[] => {
|
||||
if (songsLikePlaylists.length > 0) {
|
||||
return songsLikePlaylists.map((playlist) => ({ kind: 'real', playlist }) as PlaylistRowItem);
|
||||
}
|
||||
return Array.from(
|
||||
{ length: SONGS_LIKE_PLACEHOLDER_SLOTS },
|
||||
(): PlaylistRowItem => ({
|
||||
kind: 'placeholder',
|
||||
label: 'Songs like…',
|
||||
variant: placeholderVariant('songs-like')
|
||||
})
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Home')}</title></svelte:head>
|
||||
@@ -156,6 +169,27 @@
|
||||
</HorizontalScrollRow>
|
||||
</section>
|
||||
|
||||
<!-- Songs like…: the best-performing surface, its own row (#1491).
|
||||
Depends on the system-playlists query (same as Playlists above), so
|
||||
it sits outside the home-sections {#if data} block. -->
|
||||
<section class="space-y-3">
|
||||
<HorizontalScrollRow
|
||||
rows={[songsLikeRow]}
|
||||
title="Songs like…"
|
||||
ariaLabel="Songs like"
|
||||
>
|
||||
{#snippet item(rowItem: PlaylistRowItem)}
|
||||
<div class="w-56">
|
||||
{#if rowItem.kind === 'real'}
|
||||
<PlaylistCard playlist={rowItem.playlist} />
|
||||
{:else}
|
||||
<PlaylistPlaceholderCard label={rowItem.label} variant={rowItem.variant} />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
</section>
|
||||
|
||||
{#if query.isError}
|
||||
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||
{:else if showSkeleton.value && !data}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { render, screen, within } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { emptyLikesMock } from '../test-utils/mocks/likes';
|
||||
import type { Playlist } from '$lib/api/types';
|
||||
@@ -57,7 +57,8 @@ afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('home Playlists section', () => {
|
||||
test('renders 5 placeholder cards when no playlists exist', () => {
|
||||
// Slot layout: For-You, Discover, 3× Songs-like.
|
||||
// For-You + Discover in the Playlists row, plus 3 in the dedicated
|
||||
// Songs-like row (#1491) = 5 placeholders total.
|
||||
render(Page);
|
||||
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
|
||||
expect(placeholders).toHaveLength(5);
|
||||
@@ -107,12 +108,52 @@ describe('home Playlists section', () => {
|
||||
render(Page);
|
||||
expect(screen.getByText('For You')).toBeInTheDocument();
|
||||
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
|
||||
// 1 Discover + 3 Songs-like slots = 4 placeholders alongside the
|
||||
// real For-You tile.
|
||||
// 1 Discover placeholder (Playlists row) + 3 Songs-like placeholders
|
||||
// (dedicated row) = 4 alongside the real For-You tile.
|
||||
expect(placeholders).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('renders secondary system kinds (deep_cuts / new_for_you) after Songs-like slots', () => {
|
||||
test('dedicated Songs-like row shows every generated mix, uncapped and out of Playlists', () => {
|
||||
const makeSongsLike = (id: string, name: string): Playlist => ({
|
||||
id,
|
||||
user_id: 'u1',
|
||||
owner_username: 'u1',
|
||||
name,
|
||||
description: '',
|
||||
is_public: false,
|
||||
kind: 'system',
|
||||
system_variant: 'songs_like_artist',
|
||||
refreshable: false,
|
||||
seed_artist_id: 'a1',
|
||||
cover_url: '',
|
||||
track_count: 25,
|
||||
duration_sec: 1500,
|
||||
created_at: '2026-05-04T00:00:00Z',
|
||||
updated_at: '2026-05-04T00:00:00Z'
|
||||
});
|
||||
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
|
||||
const owned = Array.from({ length: 6 }, (_, i) => makeSongsLike(`sl${i}`, `Songs like ${i}`));
|
||||
(createPlaylistsQuery as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(kind?: string) =>
|
||||
kind === 'system'
|
||||
? readable({ data: { owned, public: [] }, isPending: false, isError: false })
|
||||
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
|
||||
);
|
||||
const { container } = render(Page);
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
expect(screen.getByText(`Songs like ${i}`)).toBeInTheDocument();
|
||||
}
|
||||
// The mixes live in the Songs-like row, not the Playlists carousel.
|
||||
const playlistsSection = container.querySelector('[aria-label="Playlists"]') as HTMLElement;
|
||||
expect(within(playlistsSection).queryByText('Songs like 0')).toBeNull();
|
||||
const songsSection = container.querySelector('[aria-label="Songs like"]') as HTMLElement;
|
||||
expect(within(songsSection).getByText('Songs like 0')).toBeInTheDocument();
|
||||
// No placeholders once real mixes exist.
|
||||
expect(within(songsSection).queryByTestId('playlist-placeholder-card')).toBeNull();
|
||||
});
|
||||
|
||||
test('renders secondary system kinds (deep_cuts / new_for_you) in the Playlists row', () => {
|
||||
// Operator backflow 2026-06-01: web Home surfaces the 5 secondary
|
||||
// system kinds when generated. No placeholders for them — they
|
||||
// depend on library shape, so missing means "not enough data."
|
||||
|
||||
Reference in New Issue
Block a user