feat(web/playlists): For-You tile play button refreshes and plays
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||||
|
const got = await refreshForYou();
|
||||||
|
expect(got.playlist_id).toBe(null);
|
||||||
|
expect(got.track_ids).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -108,3 +108,24 @@ export async function refreshDiscover(): Promise<RefreshDiscoverResponse> {
|
|||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
})) as RefreshDiscoverResponse;
|
})) 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<RefreshForYouResponse> {
|
||||||
|
return (await apiFetch('/api/playlists/system/for-you/refresh', {
|
||||||
|
method: 'POST'
|
||||||
|
})) as RefreshForYouResponse;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import type { Playlist, PlaylistTrack, TrackRef } from '$lib/api/types';
|
import type { Playlist, PlaylistTrack, TrackRef } from '$lib/api/types';
|
||||||
import { user } from '$lib/auth/store.svelte';
|
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 { qk } from '$lib/api/queries';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
|
|
||||||
@@ -60,7 +60,22 @@
|
|||||||
if (starting) return;
|
if (starting) return;
|
||||||
starting = true;
|
starting = true;
|
||||||
try {
|
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);
|
const refs = toTrackRefs(detail.tracks);
|
||||||
if (refs.length > 0) {
|
if (refs.length > 0) {
|
||||||
playQueue(refs, 0);
|
playQueue(refs, 0);
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ vi.mock('$lib/api/playlists', () => ({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
} as PlaylistDetail),
|
} 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', () => ({
|
vi.mock('$lib/player/store.svelte', () => ({
|
||||||
@@ -216,4 +217,94 @@ describe('PlaylistCard', () => {
|
|||||||
await fireEvent.click(refreshItem);
|
await fireEvent.click(refreshItem);
|
||||||
await waitFor(() => expect(refreshDiscover).toHaveBeenCalled());
|
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<typeof vi.fn>).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<typeof vi.fn>).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user