From e48d84cde1f1de04aa7422e1d4b57f3c2c07fe9c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 10:40:23 -0400 Subject: [PATCH] feat(web/playlists): For-You tile play button refreshes and plays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click on the For-You tile's play button now triggers a synchronous refresh, then enqueues + auto-plays the freshly-built playlist. Two-click-target tile pattern: tile BODY click still navigates to the playlist detail page (existing behavior); the play button is the new generate-and-play action. Behavior is gated on playlist.system_variant === 'for_you' — every other playlist (user, Discover, Songs-like-X) keeps the existing "fetch detail, enqueue, play" flow. The atomic-replace BuildSystem- Playlists creates a new playlist_id, so the play handler uses the refresh response's id for the follow-up getPlaylist call rather than the stale tile prop's id. Empty-library response (playlist_id: null) is a no-op — clicking play in a degenerate-empty library doesn't error, it just doesn't start playback. Caches are invalidated post-refresh so the home view re-fetches and the tile re-renders with the new id, avoiding the corner case of a click-then-tile-body 404. Tests cover the refresh-and-play happy path, the empty-library no-op, and that non-For-You playlists keep the existing flow. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/api/playlists.refresh-foryou.test.ts | 31 +++++++ web/src/lib/api/playlists.ts | 21 +++++ web/src/lib/components/PlaylistCard.svelte | 19 +++- web/src/lib/components/PlaylistCard.test.ts | 93 ++++++++++++++++++- 4 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/api/playlists.refresh-foryou.test.ts diff --git a/web/src/lib/api/playlists.refresh-foryou.test.ts b/web/src/lib/api/playlists.refresh-foryou.test.ts new file mode 100644 index 00000000..b4602730 --- /dev/null +++ b/web/src/lib/api/playlists.refresh-foryou.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { refreshForYou } from './playlists'; + +vi.mock('./client', () => ({ + apiFetch: vi.fn() +})); + +import { apiFetch } from './client'; + +describe('refreshForYou', () => { + beforeEach(() => vi.clearAllMocks()); + + it('POSTs the correct path and returns the response', async () => { + const sample = { playlist_id: 'pl-abc', track_count: 25, track_ids: ['t1', 't2'] }; + (apiFetch as unknown as ReturnType).mockResolvedValueOnce(sample); + const got = await refreshForYou(); + expect(apiFetch).toHaveBeenCalledWith( + '/api/playlists/system/for-you/refresh', + expect.objectContaining({ method: 'POST' }) + ); + expect(got).toEqual(sample); + }); + + it('handles empty-library response', async () => { + const sample = { playlist_id: null, track_count: 0, track_ids: [] }; + (apiFetch as unknown as ReturnType).mockResolvedValueOnce(sample); + const got = await refreshForYou(); + expect(got.playlist_id).toBe(null); + expect(got.track_ids).toEqual([]); + }); +}); diff --git a/web/src/lib/api/playlists.ts b/web/src/lib/api/playlists.ts index bcc9083e..31c3cf22 100644 --- a/web/src/lib/api/playlists.ts +++ b/web/src/lib/api/playlists.ts @@ -108,3 +108,24 @@ export async function refreshDiscover(): Promise { body: JSON.stringify({}) })) as RefreshDiscoverResponse; } + +// For-You refresh ------------------------------------------------------------ + +export type RefreshForYouResponse = { + playlist_id: string | null; + track_count: number; + track_ids: string[]; +}; + +// POST /api/playlists/system/for-you/refresh — synchronously +// rebuilds the calling user's For-You playlist and returns the new +// id, track count, and track id list. +// +// Used by the home Playlists row's For-You tile play button: click +// triggers refresh, the frontend then calls getPlaylist(new_id) to +// get full TrackRef snapshots and enqueues for playback. +export async function refreshForYou(): Promise { + return (await apiFetch('/api/playlists/system/for-you/refresh', { + method: 'POST' + })) as RefreshForYouResponse; +} diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index 4ba15c67..234d7114 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/svelte-query'; import type { Playlist, PlaylistTrack, TrackRef } from '$lib/api/types'; import { user } from '$lib/auth/store.svelte'; - import { getPlaylist, refreshDiscover } from '$lib/api/playlists'; + import { getPlaylist, refreshDiscover, refreshForYou } from '$lib/api/playlists'; import { qk } from '$lib/api/queries'; import { playQueue } from '$lib/player/store.svelte'; @@ -60,7 +60,22 @@ if (starting) return; starting = true; try { - const detail = await getPlaylist(playlist.id); + let detailID = playlist.id; + // For-You: refresh first so click means "give me a fresh mix + // and play it." Atomic replace creates a new playlist row, so + // the response's playlist_id is what we follow up with. + if (playlist.system_variant === 'for_you') { + const refreshed = await refreshForYou(); + if (!refreshed.playlist_id) { + // Empty library — nothing to play. + return; + } + detailID = refreshed.playlist_id; + // Invalidate caches so the home + detail views see the new + // playlist on their next read. + await queryClient.invalidateQueries({ queryKey: qk.playlists() }); + } + const detail = await getPlaylist(detailID); const refs = toTrackRefs(detail.tracks); if (refs.length > 0) { playQueue(refs, 0); diff --git a/web/src/lib/components/PlaylistCard.test.ts b/web/src/lib/components/PlaylistCard.test.ts index ac538d19..598a80dd 100644 --- a/web/src/lib/components/PlaylistCard.test.ts +++ b/web/src/lib/components/PlaylistCard.test.ts @@ -46,7 +46,8 @@ vi.mock('$lib/api/playlists', () => ({ } ] } as PlaylistDetail), - refreshDiscover: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5 }) + refreshDiscover: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5 }), + refreshForYou: vi.fn().mockResolvedValue({ playlist_id: 'p-new', track_count: 25, track_ids: ['t-1'] }) })); vi.mock('$lib/player/store.svelte', () => ({ @@ -216,4 +217,94 @@ describe('PlaylistCard', () => { await fireEvent.click(refreshItem); await waitFor(() => expect(refreshDiscover).toHaveBeenCalled()); }); + + test('For-You play button calls refreshForYou before getPlaylist', async () => { + const { refreshForYou, getPlaylist } = await import('$lib/api/playlists'); + const { playQueue } = await import('$lib/player/store.svelte'); + const forYouPlaylist: Playlist = { + ...base, + kind: 'system', + system_variant: 'for_you', + name: 'For You', + track_count: 25 + }; + // getPlaylist should return a detail with the NEW id + (getPlaylist as ReturnType).mockResolvedValueOnce({ + id: 'p-new', + user_id: 'u-self', + owner_username: 'me', + name: 'For You', + description: '', + is_public: false, + kind: 'system', + system_variant: 'for_you', + seed_artist_id: null, + cover_url: '', + track_count: 1, + duration_sec: 60, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + tracks: [ + { + position: 0, + track_id: 't-1', + album_id: 'al-1', + artist_id: 'ar-1', + title: 'Test Track', + artist_name: 'Test Artist', + album_title: 'Test Album', + duration_sec: 60, + stream_url: '/s/t-1', + added_at: '2026-01-01T00:00:00Z' + } + ] + } as PlaylistDetail); + + render(PlaylistCard, { props: { playlist: forYouPlaylist } }); + const playButton = screen.getByLabelText(/Play For You/i); + await fireEvent.click(playButton); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(refreshForYou).toHaveBeenCalled(); + expect(getPlaylist).toHaveBeenCalledWith('p-new'); + expect(playQueue).toHaveBeenCalled(); + }); + + test('For-You play with empty-library response does not call playQueue', async () => { + const { refreshForYou } = await import('$lib/api/playlists'); + const { playQueue } = await import('$lib/player/store.svelte'); + (refreshForYou as ReturnType).mockResolvedValueOnce({ + playlist_id: null, + track_count: 0, + track_ids: [] + }); + const forYouPlaylist: Playlist = { + ...base, + kind: 'system', + system_variant: 'for_you', + name: 'For You', + track_count: 1 + }; + render(PlaylistCard, { props: { playlist: forYouPlaylist } }); + const playButton = screen.getByLabelText(/Play For You/i); + await fireEvent.click(playButton); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(refreshForYou).toHaveBeenCalled(); + expect(playQueue).not.toHaveBeenCalled(); + }); + + test('non-For-You play button does NOT call refreshForYou', async () => { + const { refreshForYou, getPlaylist } = await import('$lib/api/playlists'); + const { playQueue } = await import('$lib/player/store.svelte'); + + render(PlaylistCard, { props: { playlist: base } }); + const playButton = screen.getByLabelText(/Play Saturday morning/i); + await fireEvent.click(playButton); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(refreshForYou).not.toHaveBeenCalled(); + expect(getPlaylist).toHaveBeenCalledWith('p-1'); + expect(playQueue).toHaveBeenCalled(); + }); });