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, onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist, onPlayPlaylist = onPlayPlaylist,
) )
genreMixSection(
playlists = sections.playlists,
offline = offline,
onPlaylistClick = onPlaylistClick,
onPlayPool = onPlayPool,
onPlayPlaylist = onPlayPlaylist,
)
youMightLikeSection( youMightLikeSection(
albums = sections.youMightLikeAlbums, albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists, 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( private fun LazyListScope.recentlyAddedSection(
albums: List<HomeTile<AlbumRef>>, albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit, 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). * Curated real-playlist order (system primaries, then secondary, then user).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and * 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 * Whether the "Regenerate" affordance applies: every system playlist
* EXCEPT songs_like_artist (the server's songs-like family exposes no * EXCEPT the kinds with several playlists per user (songs_like_artist,
* refresh trigger; rebuilds are scheduler-driven). * 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 * 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 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. * 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" "new_for_you" -> "New for you"
"on_this_day" -> "On this day" "on_this_day" -> "On this day"
"first_listens" -> "First listens" "first_listens" -> "First listens"
"genre_mix" -> "Genre mix"
null -> "" null -> ""
else -> variant.replace('_', ' ').replaceFirstChar { it.uppercase() } else -> variant.replace('_', ' ').replaceFirstChar { it.uppercase() }
} }
@@ -4,6 +4,7 @@ import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
class BuildPlaylistsRowTest { class BuildPlaylistsRowTest {
@@ -103,4 +104,40 @@ class BuildPlaylistsRowTest {
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty()) 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())
}
} }
@@ -88,6 +88,8 @@ func bucketRecSource(src string) recFamily {
return recFamily{"for_you", "For You", intentGoTo} return recFamily{"for_you", "For You", intentGoTo}
case src == "songs_like_artist": case src == "songs_like_artist":
return recFamily{"songs_like_artist", "Songs like…", intentGoTo} 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:"): case src == "radio" || strings.HasPrefix(src, "radio:"):
return recFamily{"radio", "Radio", intentGoTo} return recFamily{"radio", "Radio", intentGoTo}
case src == "discover": case src == "discover":
+208
View File
@@ -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
}
@@ -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)
);
@@ -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)
);
+121
View File
@@ -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;
+1
View File
@@ -93,6 +93,7 @@ var systemPlaylistSources = map[string]bool{
"on_this_day": true, "on_this_day": true,
"first_listens": true, "first_listens": true,
"songs_like_artist": true, "songs_like_artist": true,
"genre_mix": true,
} }
// lookupSystemPickKind resolves a system-playlist play's pick_kind // lookupSystemPickKind resolves a system-playlist play's pick_kind
+202
View File
@@ -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
}
+197
View File
@@ -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)
}
}
+198
View File
@@ -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")
}
}
}
+3 -2
View File
@@ -531,8 +531,8 @@ type systemPlaylistKind struct {
// Discover, the discovery mixes) and so can be addressed by kind: // Discover, the discovery mixes) and so can be addressed by kind:
// the generic /system/{kind}/{refresh,shuffle} endpoints + the // the generic /system/{kind}/{refresh,shuffle} endpoints + the
// per-tile refresh affordance only apply to these. Non-singleton // per-tile refresh affordance only apply to these. Non-singleton
// kinds (songs_like_artist → up to 3 per user) are played by // kinds (songs_like_artist, genre_mix — several per user) are played
// playlist id and have no by-kind endpoint. // by playlist id and have no by-kind endpoint.
Singleton bool Singleton bool
Produce systemPlaylistProducer Produce systemPlaylistProducer
} }
@@ -563,6 +563,7 @@ var systemPlaylistRegistry = func() []systemPlaylistKind {
{Key: "for_you", Singleton: true, Produce: produceForYou}, {Key: "for_you", Singleton: true, Produce: produceForYou},
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes}, {Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
{Key: "discover", Singleton: true, Produce: produceDiscover}, {Key: "discover", Singleton: true, Produce: produceDiscover},
{Key: "genre_mix", Singleton: false, Produce: produceGenreMixes},
} }
// The five discovery mixes share one Produce closure factory keyed // The five discovery mixes share one Produce closure factory keyed
// by a per-mix spec; spec list + factory live in system_mixes.go. // by a per-mix spec; spec list + factory live in system_mixes.go.
+1 -1
View File
@@ -114,7 +114,7 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
if !r.SeedArtistID.Valid { if !r.SeedArtistID.Valid {
t.Errorf("songs_like_artist row should have non-NULL seed_artist_id") 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 — // Discover and the discovery mixes are all seedless —
// no seed_artist_id required. // no seed_artist_id required.
default: default:
+6 -7
View File
@@ -67,13 +67,12 @@
try { try {
const variant = playlist.system_variant; const variant = playlist.system_variant;
// systemShuffle keys by variant alone, which only works for // systemShuffle keys by variant alone, which only works for
// SINGLE-instance variants (for_you, discover, deep_cuts, …). // SINGLE-instance variants (for_you, discover, deep_cuts, …) — the
// songs_like_artist has one playlist PER seed artist; using // ones the server reports as refreshable. A kind with several
// the variant alone would return the wrong one (or fail). // playlists per user (songs_like_artist, genre_mix) would get the
// For those, fall through to getPlaylist so the play handler // wrong one, so those fall through to getPlaylist and play the exact
// hits the exact playlist the user clicked on. Detect via // playlist the user clicked on.
// seed_artist_id which is non-null only for per-artist mixes. const isPerArtist = !playlist.refreshable;
const isPerArtist = playlist.seed_artist_id != null;
if (variant != null && !isPerArtist) { if (variant != null && !isPerArtist) {
// Server returns the rotation-aware order (unplayed // Server returns the rotation-aware order (unplayed
// this rotation first). Play it as-is — no client shuffle — // this rotation first). Play it as-is — no client shuffle —
@@ -266,6 +266,7 @@ describe('PlaylistCard', () => {
...base, ...base,
kind: 'system', kind: 'system',
system_variant: 'for_you', system_variant: 'for_you',
refreshable: true,
name: 'For You' name: 'For You'
}; };
render(PlaylistCard, { props: { playlist: forYouPlaylist } }); 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 () => { test('user-playlist play uses getPlaylist + plain playQueue (no source)', async () => {
const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js'); const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js'); const { playQueue } = await import('#lib/player/store.svelte.js');
+2 -2
View File
@@ -8,8 +8,8 @@ import { playlistTrackToRef } from './playlistTrackToRef';
// current snapshot and resume. // current snapshot and resume.
// //
// Single-instance variants (for_you, discover, deep_cuts, …) re-pull by // Single-instance variants (for_you, discover, deep_cuts, …) re-pull by
// variant via the rotation-aware shuffle endpoint. Per-artist mixes // variant via the rotation-aware shuffle endpoint. Kinds with several
// (songs_like_artist — one playlist per seed artist) can't be addressed by // 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 // variant alone, so they re-pull by playlist id. Mirrors the play routing
// in PlaylistCard. // in PlaylistCard.
+28
View File
@@ -50,6 +50,15 @@
.filter((p) => p.system_variant === 'songs_like_artist') .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 // Secondary system kinds the server generates that don't get pinned
// to fixed Home slots: surfaced after the Songs-like slots when they // to fixed Home slots: surfaced after the Songs-like slots when they
// exist, in server-registry order (internal/playlists/system.go). // exist, in server-registry order (internal/playlists/system.go).
@@ -189,6 +198,25 @@
</HorizontalScrollRow> </HorizontalScrollRow>
</section> </section>
<!-- Your genres: one mix per genre the user listens to. Hidden until the
server has built at least one — a genre mix needs listening history
or a library with genre tags, so there is no placeholder state. -->
{#if genreMixPlaylists.length > 0}
<section class="space-y-3">
<HorizontalScrollRow
rows={[genreMixPlaylists]}
title="Your genres"
ariaLabel="Your genres"
>
{#snippet item(playlist: Playlist)}
<div class="w-56">
<PlaylistCard {playlist} />
</div>
{/snippet}
</HorizontalScrollRow>
</section>
{/if}
{#if query.isError} {#if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} /> <ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && !data} {:else if showSkeleton.value && !data}
+46
View File
@@ -154,6 +154,52 @@ describe('home Playlists section', () => {
expect(within(songsSection).queryByTestId('playlist-placeholder-card')).toBeNull(); 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<typeof vi.fn>).mockImplementation(
(kind?: string) =>
kind === 'system'
? readable({ data: { owned, public: [] }, isPending: false, isError: false })
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
);
const { container } = render(Page);
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', () => { test('renders secondary system kinds (deep_cuts / new_for_you) in the Playlists row', () => {
// Web Home surfaces the 5 secondary // Web Home surfaces the 5 secondary
// system kinds when generated. No placeholders for them — they // system kinds when generated. No placeholders for them — they
+19 -8
View File
@@ -20,7 +20,8 @@
removePlaylistTrack, removePlaylistTrack,
reorderPlaylist, reorderPlaylist,
refreshSystem, refreshSystem,
systemShuffle systemShuffle,
listPlaylists
} from '#lib/api/playlists.js'; } from '#lib/api/playlists.js';
import { qk } from '#lib/api/queries.js'; import { qk } from '#lib/api/queries.js';
import { user } from '#lib/auth/store.svelte.js'; import { user } from '#lib/auth/store.svelte.js';
@@ -108,7 +109,7 @@
refetch: systemPlaylistRefetch({ refetch: systemPlaylistRefetch({
variant, variant,
playlistId: id, playlistId: id,
perArtist: data.seed_artist_id != null perArtist: !data.refreshable
}) })
}); });
} else { } else {
@@ -216,14 +217,24 @@
let reloadingStale = $state(false); let reloadingStale = $state(false);
async function onReloadStale() { async function onReloadStale() {
const variant = playlistQuery?.data?.system_variant; const current = playlistQuery?.data;
if (!variant) return; const variant = current?.system_variant;
if (!current || !variant) return;
reloadingStale = true; reloadingStale = true;
try { try {
// The rebuild minted a new playlist id for this variant; resolve it and // The rebuild minted a new playlist id; resolve it and navigate there
// navigate there so the page loads the fresh mix. // so the page loads the fresh mix. A kind with one playlist per user
const fresh = await systemShuffle(variant); // resolves by kind. A kind with several (Songs like…, genre mixes)
await goto(`/playlists/${fresh.id}`); // 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) { } catch (e: unknown) {
pushToast(`Couldn’t load the refreshed mix: ${errCode(e)}`, 'error'); pushToast(`Couldn’t load the refreshed mix: ${errCode(e)}`, 'error');
} finally { } finally {