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

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:
2026-11-05 22:30:41 -05:00
co-authored by Claude Opus 5.5
parent 477f453100
commit dbaa8b7d57
21 changed files with 1239 additions and 23 deletions
@@ -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())
}
}