diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index 70046f7d..573fcfa8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -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, + 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>, onAlbumClick: (String) -> Unit, @@ -926,13 +969,14 @@ private fun AlbumsRow( @Composable private fun PlaylistsRow( + title: String, rowItems: List, 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, 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, + status: SystemPlaylistsStatus, + offline: Boolean, +): List { + 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): List { val out = mutableListOf() 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 diff --git a/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt index ed09e992..206afb6d 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt @@ -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().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().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().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()) + } } diff --git a/internal/db/dbq/system_playlists.sql.go b/internal/db/dbq/system_playlists.sql.go index 4f8d1a86..a86564a9 100644 --- a/internal/db/dbq/system_playlists.sql.go +++ b/internal/db/dbq/system_playlists.sql.go @@ -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 { diff --git a/internal/db/queries/system_playlists.sql b/internal/db/queries/system_playlists.sql index 4f8a7696..b57fd079 100644 --- a/internal/db/queries/system_playlists.sql +++ b/internal/db/queries/system_playlists.sql @@ -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: diff --git a/internal/playlists/seed_selection_test.go b/internal/playlists/seed_selection_test.go index e3399f3b..14822422 100644 --- a/internal/playlists/seed_selection_test.go +++ b/internal/playlists/seed_selection_test.go @@ -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}, diff --git a/internal/playlists/system.go b/internal/playlists/system.go index b6b7f155..ce822426 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -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")). // diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 5f711c2b..266e8182 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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') + }) + ); + }); {pageTitle('Home')} @@ -156,6 +169,27 @@ + +
+ + {#snippet item(rowItem: PlaylistRowItem)} +
+ {#if rowItem.kind === 'real'} + + {:else} + + {/if} +
+ {/snippet} +
+
+ {#if query.isError} {:else if showSkeleton.value && !data} diff --git a/web/src/routes/page.test.ts b/web/src/routes/page.test.ts index b8a6bd17..cb11e9e2 100644 --- a/web/src/routes/page.test.ts +++ b/web/src/routes/page.test.ts @@ -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).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."