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
+6 -7
View File
@@ -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 —
@@ -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');
+2 -2
View File
@@ -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.
+28
View File
@@ -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 @@
</HorizontalScrollRow>
</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}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && !data}
+46
View File
@@ -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<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', () => {
// Web Home surfaces the 5 secondary
// system kinds when generated. No placeholders for them — they
+19 -8
View File
@@ -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 {