feat: genre mixes — a daily playlist per genre you listen to, on a "Your genres" Home row (#6904)
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
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>
This commit is contained in:
@@ -781,6 +781,13 @@ private fun HomeSuccessContent(
|
||||
onPlayPool = onPlayPool,
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
genreMixSection(
|
||||
playlists = sections.playlists,
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onPlayPool = onPlayPool,
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
youMightLikeSection(
|
||||
albums = sections.youMightLikeAlbums,
|
||||
artists = sections.youMightLikeArtists,
|
||||
@@ -833,6 +840,31 @@ private fun LazyListScope.songsLikeSection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Your genres" row: one mix per genre the user listens to. Hidden
|
||||
* when there is nothing to show, built or cached.
|
||||
*/
|
||||
private fun LazyListScope.genreMixSection(
|
||||
playlists: List<PlaylistRef>,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
val rowItems = buildGenreMixRow(playlists, offline)
|
||||
if (rowItems.isEmpty()) return
|
||||
item {
|
||||
PlaylistsRow(
|
||||
title = "Your genres",
|
||||
rowItems = rowItems,
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onPlayPool = onPlayPool,
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.recentlyAddedSection(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
@@ -1233,6 +1265,25 @@ internal fun buildSongsLikeRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the "Your genres" row: every genre mix, in name order so the
|
||||
* shelf reads like a stable channel list. No placeholders — a genre mix
|
||||
* needs listening history or a genre-tagged library, so none built means
|
||||
* nothing to show. Offline, fully-cached mixes come first and the rest are
|
||||
* greyed. Mirrors the web Home's genreMixPlaylists.
|
||||
*/
|
||||
internal fun buildGenreMixRow(
|
||||
owned: List<PlaylistRef>,
|
||||
offline: Boolean,
|
||||
): List<PlaylistRowItem> {
|
||||
val mixes = owned
|
||||
.filter { it.systemVariant == "genre_mix" }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
if (!offline) return mixes.map { PlaylistRowItem.Real(it) }
|
||||
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||
return (available + greyed).map { PlaylistRowItem.Real(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||
|
||||
@@ -26,10 +26,11 @@ data class PlaylistRef(
|
||||
|
||||
/**
|
||||
* Whether the "Regenerate" affordance applies: every system playlist
|
||||
* EXCEPT songs_like_artist (the server's songs-like family exposes no
|
||||
* refresh trigger; rebuilds are scheduler-driven).
|
||||
* EXCEPT the kinds with several playlists per user (songs_like_artist,
|
||||
* genre_mix), which the server can't address by kind; those rebuild on
|
||||
* the daily schedule and play by playlist id.
|
||||
*/
|
||||
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
||||
val refreshable: Boolean get() = isSystem && systemVariant !in PER_PLAYLIST_SYSTEM_VARIANTS
|
||||
|
||||
/**
|
||||
* Can't be relied on while offline — either it needs the live server to
|
||||
@@ -39,6 +40,9 @@ data class PlaylistRef(
|
||||
val unavailableOffline: Boolean get() = refreshable || !fullyCached
|
||||
}
|
||||
|
||||
/** System kinds with several playlists per user, played by playlist id. */
|
||||
private val PER_PLAYLIST_SYSTEM_VARIANTS = setOf("songs_like_artist", "genre_mix")
|
||||
|
||||
/**
|
||||
* One row of a playlist's tracks.
|
||||
*
|
||||
|
||||
@@ -171,6 +171,7 @@ private fun systemLabelFor(variant: String?): String = when (variant) {
|
||||
"new_for_you" -> "New for you"
|
||||
"on_this_day" -> "On this day"
|
||||
"first_listens" -> "First listens"
|
||||
"genre_mix" -> "Genre mix"
|
||||
null -> ""
|
||||
else -> variant.replace('_', ' ').replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuildPlaylistsRowTest {
|
||||
@@ -103,4 +104,40 @@ class BuildPlaylistsRowTest {
|
||||
|
||||
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
||||
}
|
||||
|
||||
private fun genreMix(name: String, cached: Boolean) = PlaylistRef(
|
||||
id = name,
|
||||
userId = "u",
|
||||
name = name,
|
||||
systemVariant = "genre_mix",
|
||||
trackCount = 50,
|
||||
fullyCached = cached,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `genre mixes get their own row in name order and stay out of the carousel`() {
|
||||
val owned = listOf(
|
||||
genreMix("Shoegaze mix", cached = true),
|
||||
genreMix("dub mix", cached = true),
|
||||
genreMix("Jazz mix", cached = true),
|
||||
user("u1", cached = true),
|
||||
)
|
||||
val row = buildGenreMixRow(owned, offline = false)
|
||||
val names = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.name }
|
||||
assertEquals(listOf("dub mix", "Jazz mix", "Shoegaze mix"), names)
|
||||
|
||||
val carousel = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||
.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals(listOf("u1"), carousel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `genre mixes are not refreshable, so a cached one plays offline`() {
|
||||
assertFalse(genreMix("Jazz mix", cached = true).refreshable)
|
||||
val owned = listOf(genreMix("A mix", cached = false), genreMix("B mix", cached = true))
|
||||
val names = buildGenreMixRow(owned, offline = true)
|
||||
.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.name }
|
||||
assertEquals(listOf("B mix", "A mix"), names)
|
||||
assertTrue(buildGenreMixRow(emptyList(), offline = false).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user