feat: genre mixes — a daily playlist per genre you listen to, on a "Your genres" Home row (#6904)
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 25s
release / integration (push) Failing after 4m38s
release / android (push) Successful in 4m3s
release / Build signed APK (push) Successful in 4m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 25s
release / integration (push) Failing after 4m38s
release / android (push) Successful in 4m3s
release / Build signed APK (push) Successful in 4m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A new system playlist kind, genre_mix: up to 10 "{Genre} mix" playlists per
user, rebuilt daily with the other system mixes.
Which genres: library genres with at least 25 playable tracks across 3
artists, ranked in tiers — taste-profile genres first (matched
case-insensitively, so ID3 "Post-Punk" and folksonomy "post-punk" are one),
then genres the user has played or liked, then the rest of the library, which
only tops a short shelf up to 4. A genre whose artist set is a near
duplicate of one already picked (Jaccard >= 0.5) is skipped, so "Lo-Fi" and
"Lo-Fi Hip Hop" don't both appear but "Alternative Rock" survives "Rock". A
genre that can't fill 15 tracks gives way to the next.
Each mix is 50 tracks: 3 in 5 the user already knows (played through or
liked), the rest unheard in that genre, interleaved, with the usual
per-album/per-artist caps. Tracks skipped twice and more often than played
are left out. The order changes daily and holds within a day. Picks are
stamped taste / taste_unheard, and genre_mix plays count toward rotation and
the recommendation metrics.
Migration 0102 adds genre_mix to the playlists variant constraints.
Web and Android each get a "Your genres" row in name order, hidden until a
mix exists. Kinds with several playlists per user now play by playlist id
wherever the client used to test seed_artist_id (web card, detail page
self-heal), keyed on the server's refreshable flag; on Android genre_mix
joins songs_like_artist as not refreshable. The web detail page's
"mix was rebuilt" reload now finds a Songs-like or genre mix by name instead
of calling the by-kind shuffle, which those kinds don't have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user