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 db1fcf4b..8e4cd1a5 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 @@ -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, + 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>, 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, + offline: Boolean, +): List { + 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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt index 17af814a..a60bbf72 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt @@ -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. * diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt index afaeca0f..914b850c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt @@ -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() } } 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 2315074a..a686bb99 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 @@ -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().map { it.playlist.name } + assertEquals(listOf("dub mix", "Jazz mix", "Shoegaze mix"), names) + + val carousel = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false) + .filterIsInstance().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().map { it.playlist.name } + assertEquals(listOf("B mix", "A mix"), names) + assertTrue(buildGenreMixRow(emptyList(), offline = false).isEmpty()) + } } diff --git a/internal/api/me_recommendation_metrics.go b/internal/api/me_recommendation_metrics.go index ae2a796e..3d2c5fc5 100644 --- a/internal/api/me_recommendation_metrics.go +++ b/internal/api/me_recommendation_metrics.go @@ -88,6 +88,8 @@ func bucketRecSource(src string) recFamily { return recFamily{"for_you", "For You", intentGoTo} case src == "songs_like_artist": return recFamily{"songs_like_artist", "Songs like…", intentGoTo} + case src == "genre_mix": + return recFamily{"genre_mix", "Genre mixes", intentGoTo} case src == "radio" || strings.HasPrefix(src, "radio:"): return recFamily{"radio", "Radio", intentGoTo} case src == "discover": diff --git a/internal/db/dbq/genre_mixes.sql.go b/internal/db/dbq/genre_mixes.sql.go new file mode 100644 index 00000000..9f92aaca --- /dev/null +++ b/internal/db/dbq/genre_mixes.sql.go @@ -0,0 +1,208 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: genre_mixes.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const listGenreMixCandidates = `-- name: ListGenreMixCandidates :many + +WITH library_genres AS ( + SELECT lower(trim(g.g)) AS genre_key, + min(trim(g.g))::text AS label, + COUNT(DISTINCT t.id) AS track_count, + array_agg(DISTINCT t.artist_id)::uuid[] AS artist_ids + FROM tracks t + CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) + WHERE t.missing_since IS NULL + AND trim(g.g) <> '' + GROUP BY lower(trim(g.g)) + HAVING COUNT(DISTINCT t.id) >= 25 + AND COUNT(DISTINCT t.artist_id) >= 3 +), +taste AS ( + SELECT lower(trim(tpt.tag)) AS genre_key, SUM(tpt.weight) AS weight + FROM taste_profile_tags tpt + WHERE tpt.user_id = $1 + GROUP BY lower(trim(tpt.tag)) + HAVING SUM(tpt.weight) > 0 +), +listened AS ( + SELECT t.id + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 AND pe.was_skipped = false + UNION ALL + SELECT gl.track_id + FROM general_likes gl + WHERE gl.user_id = $1 +), +history AS ( + SELECT lower(trim(g.g)) AS genre_key, COUNT(*) AS plays + FROM listened l + JOIN tracks t ON t.id = l.id + CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) + WHERE trim(g.g) <> '' + GROUP BY lower(trim(g.g)) +) +SELECT lg.genre_key::text AS genre_key, + lg.label AS label, + (CASE WHEN ta.genre_key IS NOT NULL THEN 1 + WHEN h.genre_key IS NOT NULL THEN 2 + ELSE 3 END)::int AS tier, + (CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight + WHEN h.genre_key IS NOT NULL THEN h.plays::float8 + ELSE lg.track_count::float8 END)::float8 AS score, + lg.artist_ids + FROM library_genres lg + LEFT JOIN taste ta ON ta.genre_key = lg.genre_key + LEFT JOIN history h ON h.genre_key = lg.genre_key + ORDER BY tier, score DESC, lg.genre_key + LIMIT 60 +` + +type ListGenreMixCandidatesRow struct { + GenreKey string + Label string + Tier int32 + Score float64 + ArtistIds []pgtype.UUID +} + +// Candidate queries for the genre mixes: one daily system playlist per genre +// the user listens to. Genres live inline on tracks.genre as a delimited +// string and are split on [;,] the same way the Discover and radio tag arms +// split them. Matching is case-insensitive, so the ID3 "Post-Punk" and the +// folksonomy "post-punk" in the taste profile count as one genre. +// Library genres big enough to carry a mix (>= 25 playable tracks across +// >= 3 artists), each tagged with how close it is to the user's listening: +// +// tier 1: a positive taste-profile genre, scored by its taste weight +// tier 2: a genre of tracks the user has played through or liked, scored +// by how many — catches a profile that has not been built yet or +// has decayed past its window +// tier 3: any other library genre, scored by its size +// +// artist_ids lets the caller skip a genre whose artists mostly repeat one +// already picked (Indie Rock after Alternative Rock). +// $1 user_id. +func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID) ([]ListGenreMixCandidatesRow, error) { + rows, err := q.db.Query(ctx, listGenreMixCandidates, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListGenreMixCandidatesRow + for rows.Next() { + var i ListGenreMixCandidatesRow + if err := rows.Scan( + &i.GenreKey, + &i.Label, + &i.Tier, + &i.Score, + &i.ArtistIds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listGenreMixTracks = `-- name: ListGenreMixTracks :many +WITH play_counts AS ( + SELECT pe.track_id, COUNT(*) AS c + FROM play_events pe + WHERE pe.user_id = $1 AND pe.was_skipped = false + GROUP BY pe.track_id +), +skip_counts AS ( + SELECT pe.track_id, COUNT(*) AS c + FROM play_events pe + WHERE pe.user_id = $1 AND pe.was_skipped = true + GROUP BY pe.track_id +), +eligible AS ( + SELECT t.id, t.album_id, t.artist_id, + (COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar, + md5(t.id::text || $2::text) AS tiebreak + FROM tracks t + LEFT JOIN play_counts pc ON pc.track_id = t.id + LEFT JOIN skip_counts sc ON sc.track_id = t.id + LEFT JOIN general_likes gl ON gl.user_id = $1 AND gl.track_id = t.id + WHERE t.missing_since IS NULL -- never offer a file that is gone + AND NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0)) + AND EXISTS ( + SELECT 1 + FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) + WHERE lower(trim(g.g)) = $3::text + ) + AND NOT EXISTS ( + SELECT 1 FROM quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +), +ranked AS ( + SELECT e.id, e.album_id, e.artist_id, e.familiar, e.tiebreak, row_number() OVER (PARTITION BY e.familiar ORDER BY e.tiebreak) AS rn + FROM eligible e +) +SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar + FROM ranked r + WHERE r.rn <= 200 + ORDER BY r.familiar DESC, r.tiebreak +` + +type ListGenreMixTracksParams struct { + UserID pgtype.UUID + DateSeed string + GenreKey string +} + +type ListGenreMixTracksRow struct { + ID pgtype.UUID + AlbumID pgtype.UUID + ArtistID pgtype.UUID + Familiar bool +} + +// Playable tracks carrying one genre, split into the two halves a genre mix +// blends: familiar (the user has played it through or liked it) and unheard. +// Up to 200 of each, each half in a daily-deterministic order so the mix +// changes day to day and holds still within a day. +// +// A track the user keeps skipping stays out: twice-skipped and skipped more +// often than played through. The second half of that test keeps a favourite +// with a couple of stray skips in. +func (q *Queries) ListGenreMixTracks(ctx context.Context, arg ListGenreMixTracksParams) ([]ListGenreMixTracksRow, error) { + rows, err := q.db.Query(ctx, listGenreMixTracks, arg.UserID, arg.DateSeed, arg.GenreKey) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListGenreMixTracksRow + for rows.Next() { + var i ListGenreMixTracksRow + if err := rows.Scan( + &i.ID, + &i.AlbumID, + &i.ArtistID, + &i.Familiar, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/migrations/0102_genre_mix_variant.down.sql b/internal/db/migrations/0102_genre_mix_variant.down.sql new file mode 100644 index 00000000..b039f04c --- /dev/null +++ b/internal/db/migrations/0102_genre_mix_variant.down.sql @@ -0,0 +1,37 @@ +-- Restores the 0028 constraint set. Genre-mix rows would violate it, so they +-- go first. +DELETE FROM playlists WHERE system_variant = 'genre_mix'; + +ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_kind_variant_consistent; +ALTER TABLE playlists + ADD CONSTRAINT playlists_kind_variant_consistent CHECK ( + (kind = 'user' AND system_variant IS NULL) + OR + (kind = 'system' AND system_variant IN ( + 'for_you', 'songs_like_artist', 'discover', + 'deep_cuts', 'rediscover', 'new_for_you', + 'on_this_day', 'first_listens' + )) + ); + +ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_seed_consistent; +ALTER TABLE playlists + ADD CONSTRAINT playlists_seed_consistent CHECK ( + (system_variant IS NULL AND seed_artist_id IS NULL) + OR + (system_variant = 'for_you' AND seed_artist_id IS NULL) + OR + (system_variant = 'discover' AND seed_artist_id IS NULL) + OR + (system_variant = 'deep_cuts' AND seed_artist_id IS NULL) + OR + (system_variant = 'rediscover' AND seed_artist_id IS NULL) + OR + (system_variant = 'new_for_you' AND seed_artist_id IS NULL) + OR + (system_variant = 'on_this_day' AND seed_artist_id IS NULL) + OR + (system_variant = 'first_listens' AND seed_artist_id IS NULL) + OR + (system_variant = 'songs_like_artist' AND seed_artist_id IS NOT NULL) + ); diff --git a/internal/db/migrations/0102_genre_mix_variant.up.sql b/internal/db/migrations/0102_genre_mix_variant.up.sql new file mode 100644 index 00000000..706ccd69 --- /dev/null +++ b/internal/db/migrations/0102_genre_mix_variant.up.sql @@ -0,0 +1,43 @@ +-- Genre mixes: one daily system playlist per genre the user listens to +-- ("Post-Punk mix", "Jazz mix", ...). Several per user, like +-- songs_like_artist, but seedless: the genre lives in the playlist name and +-- seed_artist_id is NULL. +-- +-- Postgres has no ALTER CONSTRAINT for CHECK, so both constraints are +-- dropped and re-added with 'genre_mix' in the accepted set. + +ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_kind_variant_consistent; +ALTER TABLE playlists + ADD CONSTRAINT playlists_kind_variant_consistent CHECK ( + (kind = 'user' AND system_variant IS NULL) + OR + (kind = 'system' AND system_variant IN ( + 'for_you', 'songs_like_artist', 'discover', + 'deep_cuts', 'rediscover', 'new_for_you', + 'on_this_day', 'first_listens', 'genre_mix' + )) + ); + +ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_seed_consistent; +ALTER TABLE playlists + ADD CONSTRAINT playlists_seed_consistent CHECK ( + (system_variant IS NULL AND seed_artist_id IS NULL) + OR + (system_variant = 'for_you' AND seed_artist_id IS NULL) + OR + (system_variant = 'discover' AND seed_artist_id IS NULL) + OR + (system_variant = 'deep_cuts' AND seed_artist_id IS NULL) + OR + (system_variant = 'rediscover' AND seed_artist_id IS NULL) + OR + (system_variant = 'new_for_you' AND seed_artist_id IS NULL) + OR + (system_variant = 'on_this_day' AND seed_artist_id IS NULL) + OR + (system_variant = 'first_listens' AND seed_artist_id IS NULL) + OR + (system_variant = 'genre_mix' AND seed_artist_id IS NULL) + OR + (system_variant = 'songs_like_artist' AND seed_artist_id IS NOT NULL) + ); diff --git a/internal/db/queries/genre_mixes.sql b/internal/db/queries/genre_mixes.sql new file mode 100644 index 00000000..58881e89 --- /dev/null +++ b/internal/db/queries/genre_mixes.sql @@ -0,0 +1,121 @@ +-- Candidate queries for the genre mixes: one daily system playlist per genre +-- the user listens to. Genres live inline on tracks.genre as a delimited +-- string and are split on [;,] the same way the Discover and radio tag arms +-- split them. Matching is case-insensitive, so the ID3 "Post-Punk" and the +-- folksonomy "post-punk" in the taste profile count as one genre. + +-- name: ListGenreMixCandidates :many +-- Library genres big enough to carry a mix (>= 25 playable tracks across +-- >= 3 artists), each tagged with how close it is to the user's listening: +-- +-- tier 1: a positive taste-profile genre, scored by its taste weight +-- tier 2: a genre of tracks the user has played through or liked, scored +-- by how many — catches a profile that has not been built yet or +-- has decayed past its window +-- tier 3: any other library genre, scored by its size +-- +-- artist_ids lets the caller skip a genre whose artists mostly repeat one +-- already picked (Indie Rock after Alternative Rock). +-- $1 user_id. +WITH library_genres AS ( + SELECT lower(trim(g.g)) AS genre_key, + min(trim(g.g))::text AS label, + COUNT(DISTINCT t.id) AS track_count, + array_agg(DISTINCT t.artist_id)::uuid[] AS artist_ids + FROM tracks t + CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) + WHERE t.missing_since IS NULL + AND trim(g.g) <> '' + GROUP BY lower(trim(g.g)) + HAVING COUNT(DISTINCT t.id) >= 25 + AND COUNT(DISTINCT t.artist_id) >= 3 +), +taste AS ( + SELECT lower(trim(tpt.tag)) AS genre_key, SUM(tpt.weight) AS weight + FROM taste_profile_tags tpt + WHERE tpt.user_id = $1 + GROUP BY lower(trim(tpt.tag)) + HAVING SUM(tpt.weight) > 0 +), +listened AS ( + SELECT t.id + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 AND pe.was_skipped = false + UNION ALL + SELECT gl.track_id + FROM general_likes gl + WHERE gl.user_id = $1 +), +history AS ( + SELECT lower(trim(g.g)) AS genre_key, COUNT(*) AS plays + FROM listened l + JOIN tracks t ON t.id = l.id + CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) + WHERE trim(g.g) <> '' + GROUP BY lower(trim(g.g)) +) +SELECT lg.genre_key::text AS genre_key, + lg.label AS label, + (CASE WHEN ta.genre_key IS NOT NULL THEN 1 + WHEN h.genre_key IS NOT NULL THEN 2 + ELSE 3 END)::int AS tier, + (CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight + WHEN h.genre_key IS NOT NULL THEN h.plays::float8 + ELSE lg.track_count::float8 END)::float8 AS score, + lg.artist_ids + FROM library_genres lg + LEFT JOIN taste ta ON ta.genre_key = lg.genre_key + LEFT JOIN history h ON h.genre_key = lg.genre_key + ORDER BY tier, score DESC, lg.genre_key + LIMIT 60; + +-- name: ListGenreMixTracks :many +-- Playable tracks carrying one genre, split into the two halves a genre mix +-- blends: familiar (the user has played it through or liked it) and unheard. +-- Up to 200 of each, each half in a daily-deterministic order so the mix +-- changes day to day and holds still within a day. +-- +-- A track the user keeps skipping stays out: twice-skipped and skipped more +-- often than played through. The second half of that test keeps a favourite +-- with a couple of stray skips in. +WITH play_counts AS ( + SELECT pe.track_id, COUNT(*) AS c + FROM play_events pe + WHERE pe.user_id = sqlc.arg(user_id) AND pe.was_skipped = false + GROUP BY pe.track_id +), +skip_counts AS ( + SELECT pe.track_id, COUNT(*) AS c + FROM play_events pe + WHERE pe.user_id = sqlc.arg(user_id) AND pe.was_skipped = true + GROUP BY pe.track_id +), +eligible AS ( + SELECT t.id, t.album_id, t.artist_id, + (COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar, + md5(t.id::text || sqlc.arg(date_seed)::text) AS tiebreak + FROM tracks t + LEFT JOIN play_counts pc ON pc.track_id = t.id + LEFT JOIN skip_counts sc ON sc.track_id = t.id + LEFT JOIN general_likes gl ON gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id + WHERE t.missing_since IS NULL -- never offer a file that is gone + AND NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0)) + AND EXISTS ( + SELECT 1 + FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g) + WHERE lower(trim(g.g)) = sqlc.arg(genre_key)::text + ) + AND NOT EXISTS ( + SELECT 1 FROM quarantine q + WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id + ) +), +ranked AS ( + SELECT e.*, row_number() OVER (PARTITION BY e.familiar ORDER BY e.tiebreak) AS rn + FROM eligible e +) +SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar + FROM ranked r + WHERE r.rn <= 200 + ORDER BY r.familiar DESC, r.tiebreak; diff --git a/internal/playevents/writer.go b/internal/playevents/writer.go index 5fbb9eb6..66f7e4be 100644 --- a/internal/playevents/writer.go +++ b/internal/playevents/writer.go @@ -93,6 +93,7 @@ var systemPlaylistSources = map[string]bool{ "on_this_day": true, "first_listens": true, "songs_like_artist": true, + "genre_mix": true, } // lookupSystemPickKind resolves a system-playlist play's pick_kind diff --git a/internal/playlists/genre_mixes.go b/internal/playlists/genre_mixes.go new file mode 100644 index 00000000..82fbf8ac --- /dev/null +++ b/internal/playlists/genre_mixes.go @@ -0,0 +1,202 @@ +package playlists + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Genre mixes: one daily playlist per genre the user listens to, shown on +// their own Home row. Each mix is a blend of the user's familiar tracks in +// that genre and tracks in it they have not heard yet. +// +// Which genres: ListGenreMixCandidates ranks library genres in three tiers +// (taste profile → listening history → the rest of the library). Tier 3 +// only fills a shelf the user's own history cannot: a user with a handful of +// listened genres gets those, topped up to genreMixMinShelf, rather than +// ten channels of music they never play. +// +// The set of genres is stable from day to day, like a list of channels; the +// tracks inside each mix change daily. + +const ( + // genreMixCount is the most genre mixes a user gets. + genreMixCount = 10 + // genreMixMinShelf is how many mixes tier-3 genres may fill up to when + // the user's history yields fewer. + genreMixMinShelf = 4 + // genreMixLen is each mix's length. + genreMixLen = 50 + // genreMixMinLen withholds a genre whose eligible tracks can't make a + // real mix; the next candidate genre takes its place. + genreMixMinLen = 15 + // A mix is genreMixFamiliarParts in every genreMixParts tracks the user + // already knows (3 in 5); the rest is unheard tracks in the genre. + // Either side running short is filled from the other. + genreMixFamiliarParts = 3 + genreMixParts = 5 + // genreMixMaxOverlap skips a candidate genre whose artist set is a near + // duplicate of one already picked: shared artists / all artists of the + // pair (Jaccard) at or above this. Symmetric on purpose — a broad + // "Rock" does not knock out every rock subgenre, but "Lo-Fi" and + // "Lo-Fi Hip Hop" with the same artists collapse to one mix. + genreMixMaxOverlap = 0.5 + // genreMixTier3 is the tier of genres the user has no history with. + genreMixTier3 = 3 +) + +// genreCandidate is one row of ListGenreMixCandidates. +type genreCandidate struct { + Key string + Label string + Tier int32 + ArtistIDs []pgtype.UUID +} + +// genreTrack is one row of ListGenreMixTracks. +type genreTrack struct { + discoverTrack + Familiar bool +} + +// produceGenreMixes builds up to genreMixCount "{Genre} mix" playlists. +// The candidate query failing is fatal to the build, like the Songs-like +// seed query; a single genre's track query failing skips that genre. +func produceGenreMixes( + ctx context.Context, q *dbq.Queries, logger *slog.Logger, + userID pgtype.UUID, dateStr string, _ time.Time, +) ([]builtPlaylist, error) { + rows, err := q.ListGenreMixCandidates(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list genre mix candidates: %w", err) + } + cands := make([]genreCandidate, len(rows)) + for i, r := range rows { + cands[i] = genreCandidate{Key: r.GenreKey, Label: r.Label, Tier: r.Tier, ArtistIDs: r.ArtistIds} + } + build := func(c genreCandidate) []rankedCandidate { + trackRows, terr := q.ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{ + UserID: userID, + GenreKey: c.Key, + DateSeed: dateStr, + }) + if terr != nil { + logger.Warn("system playlist: genre mix tracks query failed; skipping genre", + "user_id", uuidStringPL(userID), "genre", c.Label, "err", terr) + return nil + } + tracks := make([]genreTrack, len(trackRows)) + for i, r := range trackRows { + tracks[i] = genreTrack{ + discoverTrack: discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}, + Familiar: r.Familiar, + } + } + return composeGenreMix(tracks, genreMixLen) + } + return selectGenreMixes(cands, build), nil +} + +// selectGenreMixes walks the candidates in rank order and keeps the ones +// that make a real mix, skipping a genre that mostly repeats the artists of +// one already kept, and taking tier-3 genres only while the shelf is below +// genreMixMinShelf. +func selectGenreMixes(cands []genreCandidate, build func(genreCandidate) []rankedCandidate) []builtPlaylist { + var kept []genreCandidate + var out []builtPlaylist + for _, c := range cands { + if len(out) >= genreMixCount { + break + } + if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf { + break + } + if overlapsKept(c, kept) { + continue + } + tracks := build(c) + if len(tracks) < genreMixMinLen { + continue + } + kept = append(kept, c) + out = append(out, builtPlaylist{ + Name: c.Label + " mix", + Variant: "genre_mix", + Tracks: tracks, + }) + } + return out +} + +// overlapsKept reports whether c's artists are a near duplicate (Jaccard at +// or above genreMixMaxOverlap) of any single kept genre's. +func overlapsKept(c genreCandidate, kept []genreCandidate) bool { + if len(c.ArtistIDs) == 0 { + return false + } + for _, k := range kept { + in := make(map[pgtype.UUID]struct{}, len(k.ArtistIDs)) + for _, a := range k.ArtistIDs { + in[a] = struct{}{} + } + shared := 0 + for _, a := range c.ArtistIDs { + if _, ok := in[a]; ok { + shared++ + } + } + union := len(in) + len(c.ArtistIDs) - shared + if float64(shared) >= genreMixMaxOverlap*float64(union) { + return true + } + } + return false +} + +// composeGenreMix interleaves familiar and unheard tracks 3 to 5 +// (familiar, familiar, unheard, familiar, unheard, …), +// continuing with whichever side remains once the other runs out, then +// applies the per-album / per-artist caps and truncates to n. A genre too +// narrow for the caps is topped up from the uncapped pool, as the discovery +// mixes are. Familiar tracks are stamped pick_kind 'taste' and unheard ones +// 'taste_unheard', so skips can be attributed to each half. +func composeGenreMix(tracks []genreTrack, n int) []rankedCandidate { + var familiar, unheard []discoverTrack + for _, t := range tracks { + d := t.discoverTrack + if t.Familiar { + d.PickKind = pickKindTaste + familiar = append(familiar, d) + } else { + d.PickKind = pickKindTasteUnheard + unheard = append(unheard, d) + } + } + pool := make([]discoverTrack, 0, len(familiar)+len(unheard)) + fi, ui := 0, 0 + for fi < len(familiar) || ui < len(unheard) { + takeFamiliar := fi < len(familiar) && + (ui >= len(unheard) || fi*genreMixParts < genreMixFamiliarParts*(fi+ui+1)) + if takeFamiliar { + pool = append(pool, familiar[fi]) + fi++ + } else { + pool = append(pool, unheard[ui]) + ui++ + } + } + capped := topUpFromRaw(capByAlbumAndArtist(pool), pool, n) + if len(capped) > n { + capped = capped[:n] + } + out := make([]rankedCandidate, len(capped)) + for i, t := range capped { + out[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind} + } + return out +} diff --git a/internal/playlists/genre_mixes_db_test.go b/internal/playlists/genre_mixes_db_test.go new file mode 100644 index 00000000..1a8fab72 --- /dev/null +++ b/internal/playlists/genre_mixes_db_test.go @@ -0,0 +1,197 @@ +package playlists_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" +) + +// seedGenre creates artists × perArtist tracks tagged genre, each artist on +// its own album, and returns the tracks. +func seedGenre(t *testing.T, pool *pgxpool.Pool, genre string, artists, perArtist int) []dbq.Track { + t.Helper() + var out []dbq.Track + for a := 0; a < artists; a++ { + for k := 0; k < perArtist; k++ { + tr := seedTrack(t, pool, fmt.Sprintf("%s %d-%d", genre, a, k), fmt.Sprintf("%s Artist %d", genre, a)) + setGenre(t, pool, tr.ID, genre) + out = append(out, tr) + } + } + return out +} + +// TestListGenreMixCandidates_TiersAndSizeFloor: a taste-profile genre is +// tier 1 (matched case-insensitively), a genre the user has only played is +// tier 2, an untouched one is tier 3, and a genre too small for a mix is not +// offered at all. +func TestListGenreMixCandidates_TiersAndSizeFloor(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + u := seedUser(t, pool, "gmixcand") + now := time.Now().UTC() + + seedGenre(t, pool, "Shoegaze", 6, 5) + kraut := seedGenre(t, pool, "Krautrock", 6, 5) + seedGenre(t, pool, "Dub", 6, 5) + seedGenre(t, pool, "Zydeco", 2, 5) // 10 tracks, 2 artists: below the floor + + seedTasteTag(t, pool, u.ID, "shoegaze", 4) + seedPlayEvent(t, pool, u.ID, kraut[0].ID, now.Add(-time.Hour), false) + + rows, err := dbq.New(pool).ListGenreMixCandidates(ctx, u.ID) + if err != nil { + t.Fatalf("list candidates: %v", err) + } + want := map[string]int32{"Shoegaze": 1, "Krautrock": 2, "Dub": 3} + tier := map[string]int32{} + order := []string{} + for _, r := range rows { + tier[r.Label] = r.Tier + if _, mine := want[r.Label]; mine { + order = append(order, r.Label) + } + if r.Label == "Shoegaze" && len(r.ArtistIds) != 6 { + t.Errorf("Shoegaze artist_ids = %d, want 6", len(r.ArtistIds)) + } + } + for label, w := range want { + if got, ok := tier[label]; !ok || got != w { + t.Errorf("%s tier = %d (present %v), want %d", label, got, ok, w) + } + } + if _, ok := tier["Zydeco"]; ok { + t.Error("Zydeco (10 tracks, 2 artists) offered as a genre mix") + } + if strings.Join(order, ",") != "Shoegaze,Krautrock,Dub" { + t.Errorf("order = %v, want tier order Shoegaze, Krautrock, Dub", order) + } +} + +// TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks: played and +// liked tracks come back familiar, the rest unheard; a track skipped more +// often than played through is left out, while a favourite with stray skips +// stays; tracks of other genres never appear. +func TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + u := seedUser(t, pool, "gmixtracks") + now := time.Now().UTC() + + shoegaze := seedGenre(t, pool, "Shoegaze", 6, 5) + other := seedGenre(t, pool, "Dub", 3, 2) + played, liked, rejected, favourite := shoegaze[0], shoegaze[1], shoegaze[2], shoegaze[3] + + seedPlayEvent(t, pool, u.ID, played.ID, now.Add(-time.Hour), false) + if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, liked.ID); err != nil { + t.Fatalf("like: %v", err) + } + for i := 0; i < 2; i++ { + seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-time.Duration(i+2)*time.Hour), true) + } + for i := 0; i < 5; i++ { + seedPlayEvent(t, pool, u.ID, favourite.ID, now.Add(-time.Duration(i+10)*time.Hour), false) + } + for i := 0; i < 2; i++ { + seedPlayEvent(t, pool, u.ID, favourite.ID, now.Add(-time.Duration(i+20)*time.Hour), true) + } + + rows, err := dbq.New(pool).ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{ + UserID: u.ID, DateSeed: "2026-11-05", GenreKey: "shoegaze", + }) + if err != nil { + t.Fatalf("list tracks: %v", err) + } + familiar := map[pgtype.UUID]bool{} + for _, r := range rows { + familiar[r.ID] = r.Familiar + } + if len(rows) != len(shoegaze)-1 { + t.Errorf("rows = %d, want %d (every Shoegaze track but the rejected one)", len(rows), len(shoegaze)-1) + } + for _, tr := range other { + if _, in := familiar[tr.ID]; in { + t.Fatalf("a Dub track appeared in the Shoegaze pool") + } + } + if _, in := familiar[rejected.ID]; in { + t.Error("a track skipped twice and never played through was offered") + } + for name, tr := range map[string]dbq.Track{"played": played, "liked": liked, "favourite": favourite} { + if f, in := familiar[tr.ID]; !in || !f { + t.Errorf("%s track: present %v familiar %v, want present and familiar", name, in, f) + } + } + if f := familiar[shoegaze[4].ID]; f { + t.Error("an untouched track came back familiar") + } +} + +// TestBuildSystemPlaylists_GenreMixes: a full build gives the user a mix for +// the genre they listen to, holding only that genre's tracks, with both +// halves of the blend stamped. +func TestBuildSystemPlaylists_GenreMixes(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + u := seedUser(t, pool, "gmixbuild") + now := time.Now().UTC() + + shoegaze := seedGenre(t, pool, "Shoegaze", 8, 5) + seedGenre(t, pool, "Dub", 6, 5) + seedTasteTag(t, pool, u.ID, "Shoegaze", 5) + for i, tr := range shoegaze[:12] { + seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(i+1)*time.Hour), false) + } + + if err := playlists.BuildSystemPlaylists(ctx, pool, discardLogger(), u.ID, now, t.TempDir()); err != nil { + t.Fatalf("build: %v", err) + } + + var plID pgtype.UUID + if err := pool.QueryRow(ctx, ` + SELECT id FROM playlists + WHERE user_id = $1 AND system_variant = 'genre_mix' AND name = 'Shoegaze mix'`, + u.ID).Scan(&plID); err != nil { + t.Fatalf("no Shoegaze mix built: %v", err) + } + + inGenre := map[pgtype.UUID]bool{} + for _, tr := range shoegaze { + inGenre[tr.ID] = true + } + rows, err := pool.Query(ctx, `SELECT track_id, pick_kind FROM playlist_tracks WHERE playlist_id = $1`, plID) + if err != nil { + t.Fatalf("list mix tracks: %v", err) + } + defer rows.Close() + kinds := map[string]int{} + n := 0 + for rows.Next() { + var id pgtype.UUID + var kind *string + if err := rows.Scan(&id, &kind); err != nil { + t.Fatalf("scan: %v", err) + } + n++ + if !inGenre[id] { + t.Errorf("track %s in the Shoegaze mix is not a Shoegaze track", uuidString(id)) + } + if kind != nil { + kinds[*kind]++ + } + } + if n < 15 { + t.Fatalf("Shoegaze mix holds %d tracks, want at least 15", n) + } + if kinds["taste"] == 0 || kinds["taste_unheard"] == 0 { + t.Errorf("pick kinds = %v, want both taste and taste_unheard", kinds) + } +} diff --git a/internal/playlists/genre_mixes_test.go b/internal/playlists/genre_mixes_test.go new file mode 100644 index 00000000..023d4ffc --- /dev/null +++ b/internal/playlists/genre_mixes_test.go @@ -0,0 +1,198 @@ +package playlists + +import ( + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +// genreTracks builds n tracks, each on its own album and artist (so the +// caps never bind), numbered from start. +func genreTracks(start, n int, familiar bool) []genreTrack { + out := make([]genreTrack, n) + for i := range out { + id := uuidN(start + i) + out[i] = genreTrack{ + discoverTrack: discoverTrack{ID: id, AlbumID: id, ArtistID: id}, + Familiar: familiar, + } + } + return out +} + +func kindsOf(tracks []rankedCandidate) string { + s := make([]byte, len(tracks)) + for i, t := range tracks { + switch t.PickKind { + case pickKindTaste: + s[i] = 'F' + case pickKindTasteUnheard: + s[i] = 'U' + default: + s[i] = '?' + } + } + return string(s) +} + +func TestComposeGenreMix_InterleavesThreeFamiliarToTwoUnheard(t *testing.T) { + tracks := append(genreTracks(1, 20, true), genreTracks(100, 20, false)...) + got := composeGenreMix(tracks, 10) + if want := "FFUFUFFUFU"; kindsOf(got) != want { + t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want) + } +} + +func TestComposeGenreMix_ShortSideIsFilledFromTheOther(t *testing.T) { + // Two familiar tracks only: the rest of the mix is unheard, not short. + tracks := append(genreTracks(1, 2, true), genreTracks(100, 20, false)...) + got := composeGenreMix(tracks, 10) + if len(got) != 10 { + t.Fatalf("len = %d, want 10", len(got)) + } + if want := "FFUUUUUUUU"; kindsOf(got) != want { + t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want) + } + + // No unheard tracks at all: an all-familiar mix. + got = composeGenreMix(genreTracks(1, 12, true), 10) + if want := "FFFFFFFFFF"; kindsOf(got) != want { + t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want) + } +} + +func TestComposeGenreMix_CapsOneArtistThenTopsUpANarrowGenre(t *testing.T) { + // One artist, one album, 8 tracks, plus 4 single-track artists. The cap + // keeps 2 of the big album; the top-up refills from it so the mix still + // reaches 8. + big := genreTracks(1, 8, false) + for i := range big { + big[i].AlbumID = uuidN(500) + big[i].ArtistID = uuidN(500) + } + tracks := append(big, genreTracks(100, 4, false)...) + got := composeGenreMix(tracks, 8) + if len(got) != 8 { + t.Fatalf("len = %d, want 8", len(got)) + } + // The first six are the capped pool: 2 from the big album, then the 4 + // singles — the big album does not lead the mix wholesale. + bigID := map[pgtype.UUID]bool{} + for _, b := range big { + bigID[b.ID] = true + } + inHead := 0 + for _, c := range got[:6] { + if bigID[c.TrackID] { + inHead++ + } + } + if inHead != 2 { + t.Fatalf("big-album tracks in the capped head = %d, want 2", inHead) + } +} + +func artists(ns ...int) []pgtype.UUID { + out := make([]pgtype.UUID, len(ns)) + for i, n := range ns { + out[i] = uuidN(n) + } + return out +} + +func TestOverlapsKept_NearDuplicatesCollapseButSubgenresOfABroadGenreStay(t *testing.T) { + rock := genreCandidate{Label: "Rock", ArtistIDs: artists(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)} + altRock := genreCandidate{Label: "Alternative Rock", ArtistIDs: artists(1, 2, 3)} + lofi := genreCandidate{Label: "Lo-Fi", ArtistIDs: artists(20, 21, 22, 23)} + lofiHipHop := genreCandidate{Label: "Lo-Fi Hip Hop", ArtistIDs: artists(20, 21, 22, 24)} + + if overlapsKept(altRock, []genreCandidate{rock}) { + t.Error("Alternative Rock (3 of Rock's 10 artists) collapsed into Rock; a subgenre of a broad genre should stay") + } + if !overlapsKept(lofiHipHop, []genreCandidate{rock, lofi}) { + t.Error("Lo-Fi Hip Hop (3 of 5 artists shared with Lo-Fi) was kept; near duplicates should collapse") + } + if overlapsKept(lofi, nil) { + t.Error("nothing kept yet, but overlap reported") + } +} + +// buildN returns a builder that gives every genre n tracks, except those +// listed in short, which get too few to make a mix. +func buildN(n int, short ...string) func(genreCandidate) []rankedCandidate { + thin := map[string]bool{} + for _, s := range short { + thin[s] = true + } + return func(c genreCandidate) []rankedCandidate { + if thin[c.Label] { + return make([]rankedCandidate, genreMixMinLen-1) + } + return make([]rankedCandidate, n) + } +} + +func labels(built []builtPlaylist) []string { + out := make([]string, len(built)) + for i, b := range built { + out[i] = b.Name + } + return out +} + +func distinctGenre(label string, tier int32, n int) genreCandidate { + return genreCandidate{Key: label, Label: label, Tier: tier, ArtistIDs: artists(n)} +} + +func TestSelectGenreMixes_TakesTheUsersGenresAndStopsAtTheCount(t *testing.T) { + var cands []genreCandidate + for i := 0; i < 14; i++ { + cands = append(cands, distinctGenre(string(rune('A'+i)), 1, i+1)) + } + got := selectGenreMixes(cands, buildN(30)) + if len(got) != genreMixCount { + t.Fatalf("built %d mixes, want %d", len(got), genreMixCount) + } + if got[0].Name != "A mix" || got[0].Variant != "genre_mix" { + t.Fatalf("first mix = %q (%s), want \"A mix\" (genre_mix)", got[0].Name, got[0].Variant) + } +} + +func TestSelectGenreMixes_ThinGenreGivesWayToTheNext(t *testing.T) { + cands := []genreCandidate{ + distinctGenre("Jazz", 1, 1), + distinctGenre("Dub", 1, 2), + distinctGenre("Soul", 2, 3), + } + got := labels(selectGenreMixes(cands, buildN(30, "Dub"))) + if len(got) != 2 || got[0] != "Jazz mix" || got[1] != "Soul mix" { + t.Fatalf("mixes = %v, want [Jazz mix Soul mix]", got) + } +} + +func TestSelectGenreMixes_LibraryGenresOnlyFillAShortShelf(t *testing.T) { + // Two listened genres: tier 3 tops the shelf up to genreMixMinShelf. + cands := []genreCandidate{ + distinctGenre("Jazz", 1, 1), + distinctGenre("Soul", 2, 2), + distinctGenre("Polka", 3, 3), + distinctGenre("Opera", 3, 4), + distinctGenre("Ska", 3, 5), + } + got := labels(selectGenreMixes(cands, buildN(30))) + if len(got) != genreMixMinShelf { + t.Fatalf("mixes = %v, want %d", got, genreMixMinShelf) + } + + // Enough listened genres: no tier-3 genre at all. + var full []genreCandidate + for i := 0; i < genreMixMinShelf; i++ { + full = append(full, distinctGenre(string(rune('A'+i)), 1, i+1)) + } + full = append(full, distinctGenre("Polka", 3, 99)) + for _, name := range labels(selectGenreMixes(full, buildN(30))) { + if name == "Polka mix" { + t.Fatal("a library-only genre was added to a shelf the user's own genres already fill") + } + } +} diff --git a/internal/playlists/system.go b/internal/playlists/system.go index e6fe1940..38879166 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -531,8 +531,8 @@ type systemPlaylistKind struct { // Discover, the discovery mixes) and so can be addressed by kind: // the generic /system/{kind}/{refresh,shuffle} endpoints + the // per-tile refresh affordance only apply to these. Non-singleton - // kinds (songs_like_artist → up to 3 per user) are played by - // playlist id and have no by-kind endpoint. + // kinds (songs_like_artist, genre_mix — several per user) are played + // by playlist id and have no by-kind endpoint. Singleton bool Produce systemPlaylistProducer } @@ -563,6 +563,7 @@ var systemPlaylistRegistry = func() []systemPlaylistKind { {Key: "for_you", Singleton: true, Produce: produceForYou}, {Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes}, {Key: "discover", Singleton: true, Produce: produceDiscover}, + {Key: "genre_mix", Singleton: false, Produce: produceGenreMixes}, } // The five discovery mixes share one Produce closure factory keyed // by a per-mix spec; spec list + factory live in system_mixes.go. diff --git a/internal/playlists/system_test.go b/internal/playlists/system_test.go index e5a0719e..8adb83a6 100644 --- a/internal/playlists/system_test.go +++ b/internal/playlists/system_test.go @@ -114,7 +114,7 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) { if !r.SeedArtistID.Valid { t.Errorf("songs_like_artist row should have non-NULL seed_artist_id") } - case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens": + case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens", "genre_mix": // Discover and the discovery mixes are all seedless — // no seed_artist_id required. default: diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index f3efd774..1d1b5797 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -67,13 +67,12 @@ try { const variant = playlist.system_variant; // systemShuffle keys by variant alone, which only works for - // SINGLE-instance variants (for_you, discover, deep_cuts, …). - // songs_like_artist has one playlist PER seed artist; using - // the variant alone would return the wrong one (or fail). - // For those, fall through to getPlaylist so the play handler - // hits the exact playlist the user clicked on. Detect via - // seed_artist_id which is non-null only for per-artist mixes. - const isPerArtist = playlist.seed_artist_id != null; + // SINGLE-instance variants (for_you, discover, deep_cuts, …) — the + // ones the server reports as refreshable. A kind with several + // playlists per user (songs_like_artist, genre_mix) would get the + // wrong one, so those fall through to getPlaylist and play the exact + // playlist the user clicked on. + const isPerArtist = !playlist.refreshable; if (variant != null && !isPerArtist) { // Server returns the rotation-aware order (unplayed // this rotation first). Play it as-is — no client shuffle — diff --git a/web/src/lib/components/PlaylistCard.test.ts b/web/src/lib/components/PlaylistCard.test.ts index 800da1ed..8e2725d9 100644 --- a/web/src/lib/components/PlaylistCard.test.ts +++ b/web/src/lib/components/PlaylistCard.test.ts @@ -266,6 +266,7 @@ describe('PlaylistCard', () => { ...base, kind: 'system', system_variant: 'for_you', + refreshable: true, name: 'For You' }; render(PlaylistCard, { props: { playlist: forYouPlaylist } }); @@ -280,6 +281,34 @@ describe('PlaylistCard', () => { ); }); + test('genre-mix play fetches that playlist by id, tagged genre_mix', async () => { + // Several genre mixes per user: the variant alone can't address one, + // so the card plays the exact playlist it shows. + const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js'); + const { playQueue } = await import('#lib/player/store.svelte.js'); + vi.mocked(getPlaylist).mockClear(); + vi.mocked(systemShuffle).mockClear(); + const jazzMix: Playlist = { + ...base, + id: 'gm-jazz', + kind: 'system', + system_variant: 'genre_mix', + refreshable: false, + name: 'Jazz mix' + }; + render(PlaylistCard, { props: { playlist: jazzMix } }); + await fireEvent.click(screen.getByLabelText(/Play Jazz mix/i)); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(getPlaylist).toHaveBeenCalledWith('gm-jazz'); + expect(systemShuffle).not.toHaveBeenCalled(); + expect(playQueue).toHaveBeenCalledWith( + expect.any(Array), + 0, + expect.objectContaining({ source: 'genre_mix' }) + ); + }); + test('user-playlist play uses getPlaylist + plain playQueue (no source)', async () => { const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js'); const { playQueue } = await import('#lib/player/store.svelte.js'); diff --git a/web/src/lib/playlists/systemRefetch.ts b/web/src/lib/playlists/systemRefetch.ts index 5f9be0da..a9919f59 100644 --- a/web/src/lib/playlists/systemRefetch.ts +++ b/web/src/lib/playlists/systemRefetch.ts @@ -8,8 +8,8 @@ import { playlistTrackToRef } from './playlistTrackToRef'; // current snapshot and resume. // // Single-instance variants (for_you, discover, deep_cuts, …) re-pull by -// variant via the rotation-aware shuffle endpoint. Per-artist mixes -// (songs_like_artist — one playlist per seed artist) can't be addressed by +// variant via the rotation-aware shuffle endpoint. Kinds with several +// playlists per user (songs_like_artist, genre_mix) can't be addressed by // variant alone, so they re-pull by playlist id. Mirrors the play routing // in PlaylistCard. diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index ed52bcd9..1cbd9d35 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -50,6 +50,15 @@ .filter((p) => p.system_variant === 'songs_like_artist') ); + // Genre mixes: one "{Genre} mix" per genre the user listens to, on their + // own row in name order so the shelf reads like a stable channel list. + // Mirrors Android's buildGenreMixRow (HomeScreen.kt). + const genreMixPlaylists = $derived( + (systemPlaylistsQ.data?.owned ?? []) + .filter((p) => p.system_variant === 'genre_mix') + .sort((a, b) => a.name.localeCompare(b.name)) + ); + // Secondary system kinds the server generates that don't get pinned // to fixed Home slots: surfaced after the Songs-like slots when they // exist, in server-registry order (internal/playlists/system.go). @@ -189,6 +198,25 @@ + + {#if genreMixPlaylists.length > 0} +
+ + {#snippet item(playlist: Playlist)} +
+ +
+ {/snippet} +
+
+ {/if} + {#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 af96add9..0dc45657 100644 --- a/web/src/routes/page.test.ts +++ b/web/src/routes/page.test.ts @@ -154,6 +154,52 @@ describe('home Playlists section', () => { expect(within(songsSection).queryByTestId('playlist-placeholder-card')).toBeNull(); }); + test('Your genres row lists every genre mix in name order, out of Playlists', () => { + const makeGenreMix = (id: string, name: string): Playlist => ({ + id, + user_id: 'u1', + owner_username: 'u1', + name, + description: '', + is_public: false, + kind: 'system', + system_variant: 'genre_mix', + refreshable: false, + seed_artist_id: null, + cover_url: '', + track_count: 50, + duration_sec: 3000, + created_at: '2026-11-05T00:00:00Z', + updated_at: '2026-11-05T00:00:00Z' + }); + const owned = [ + makeGenreMix('g1', 'Shoegaze mix'), + makeGenreMix('g2', 'Jazz mix'), + makeGenreMix('g3', 'Dub mix') + ]; + (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); + + const genres = container.querySelector('[aria-label="Your genres"]') as HTMLElement; + expect(genres).not.toBeNull(); + const names = within(genres) + .getAllByText(/ mix$/) + .map((el) => el.textContent?.trim()); + expect(names).toEqual(['Dub mix', 'Jazz mix', 'Shoegaze mix']); + const playlistsSection = container.querySelector('[aria-label="Playlists"]') as HTMLElement; + expect(within(playlistsSection).queryByText('Jazz mix')).toBeNull(); + }); + + test('Your genres row is absent until a genre mix exists', () => { + const { container } = render(Page); + expect(container.querySelector('[aria-label="Your genres"]')).toBeNull(); + }); + test('renders secondary system kinds (deep_cuts / new_for_you) in the Playlists row', () => { // Web Home surfaces the 5 secondary // system kinds when generated. No placeholders for them — they diff --git a/web/src/routes/playlists/[id]/+page.svelte b/web/src/routes/playlists/[id]/+page.svelte index ae439261..a70a9788 100644 --- a/web/src/routes/playlists/[id]/+page.svelte +++ b/web/src/routes/playlists/[id]/+page.svelte @@ -20,7 +20,8 @@ removePlaylistTrack, reorderPlaylist, refreshSystem, - systemShuffle + systemShuffle, + listPlaylists } from '#lib/api/playlists.js'; import { qk } from '#lib/api/queries.js'; import { user } from '#lib/auth/store.svelte.js'; @@ -108,7 +109,7 @@ refetch: systemPlaylistRefetch({ variant, playlistId: id, - perArtist: data.seed_artist_id != null + perArtist: !data.refreshable }) }); } else { @@ -216,14 +217,24 @@ let reloadingStale = $state(false); async function onReloadStale() { - const variant = playlistQuery?.data?.system_variant; - if (!variant) return; + const current = playlistQuery?.data; + const variant = current?.system_variant; + if (!current || !variant) return; reloadingStale = true; try { - // The rebuild minted a new playlist id for this variant; resolve it and - // navigate there so the page loads the fresh mix. - const fresh = await systemShuffle(variant); - await goto(`/playlists/${fresh.id}`); + // The rebuild minted a new playlist id; resolve it and navigate there + // so the page loads the fresh mix. A kind with one playlist per user + // resolves by kind. A kind with several (Songs like…, genre mixes) + // resolves by kind and name — "Jazz mix" stays "Jazz mix" — and goes + // Home when today's build no longer has that mix. + if (current.refreshable) { + const fresh = await systemShuffle(variant); + await goto(`/playlists/${fresh.id}`); + return; + } + const { owned } = await listPlaylists('system'); + const fresh = owned.find((p) => p.system_variant === variant && p.name === current.name); + await goto(fresh ? `/playlists/${fresh.id}` : '/'); } catch (e: unknown) { pushToast(`Couldn’t load the refreshed mix: ${errCode(e)}`, 'error'); } finally {