diff --git a/web/src/lib/api/playlists.refresh-discover.test.ts b/web/src/lib/api/playlists.refresh-discover.test.ts new file mode 100644 index 00000000..d8ec35a7 --- /dev/null +++ b/web/src/lib/api/playlists.refresh-discover.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { refreshDiscover } from './playlists'; + +vi.mock('./client', () => ({ + apiFetch: vi.fn() +})); + +import { apiFetch } from './client'; + +describe('refreshDiscover', () => { + beforeEach(() => vi.clearAllMocks()); + + it('POSTs the correct path and returns the response', async () => { + const sample = { playlist_id: 'abc-123', track_count: 100 }; + (apiFetch as unknown as ReturnType).mockResolvedValueOnce(sample); + const got = await refreshDiscover(); + expect(apiFetch).toHaveBeenCalledWith( + '/api/playlists/system/discover/refresh', + expect.objectContaining({ method: 'POST' }) + ); + expect(got).toEqual(sample); + }); + + it('handles empty-library response', async () => { + const sample = { playlist_id: null, track_count: 0 }; + (apiFetch as unknown as ReturnType).mockResolvedValueOnce(sample); + const got = await refreshDiscover(); + expect(got.playlist_id).toBe(null); + expect(got.track_count).toBe(0); + }); +}); diff --git a/web/src/lib/api/playlists.ts b/web/src/lib/api/playlists.ts index 7ef9aae8..bcc9083e 100644 --- a/web/src/lib/api/playlists.ts +++ b/web/src/lib/api/playlists.ts @@ -91,3 +91,20 @@ export function createPlaylistQuery(id: string) { staleTime: 30_000 }); } + +// System playlist refresh ---------------------------------------------------- + +export type RefreshDiscoverResponse = { + playlist_id: string | null; + track_count: number; +}; + +// Re-runs the daily system playlist build for the calling user and +// returns the resulting Discover playlist's id + track count. +// Used by the detail-page Refresh button and the home tile kebab. +export async function refreshDiscover(): Promise { + return (await apiFetch('/api/playlists/system/discover/refresh', { + method: 'POST', + body: JSON.stringify({}) + })) as RefreshDiscoverResponse; +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 9a1bd0f6..ac41e346 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -59,7 +59,7 @@ export type Playlist = { description: string; is_public: boolean; kind: 'user' | 'system'; - system_variant: 'for_you' | 'songs_like_artist' | null; + system_variant: 'for_you' | 'songs_like_artist' | 'discover' | null; seed_artist_id: string | null; cover_url: string; // empty string when no cover; UI renders glyph track_count: number; diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index 65078bb8..4ba15c67 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -1,15 +1,37 @@ + { menuOpen = false; }} /> + +{#if toast} +
+ {toast} +
+{/if} +
+ {#if isDiscover} +
+ + {#if menuOpen} + + {/if} +
+ {/if} ({ user: { value: { id: 'u-self', username: 'me', is_admin: false } } })); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) + }; +}); + vi.mock('$lib/api/playlists', () => ({ getPlaylist: vi.fn().mockResolvedValue({ id: 'p-1', @@ -37,13 +45,21 @@ vi.mock('$lib/api/playlists', () => ({ added_at: '2026-01-01T00:00:00Z' } ] - } as PlaylistDetail) + } as PlaylistDetail), + refreshDiscover: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5 }) })); vi.mock('$lib/player/store.svelte', () => ({ playQueue: vi.fn() })); +vi.mock('$lib/api/queries', () => ({ + qk: { + playlist: (id: string) => ['playlist', id], + playlists: (kind?: string) => ['playlists', { kind: kind ?? 'user' }] + } +})); + const base: Playlist = { id: 'p-1', user_id: 'u-self', @@ -133,4 +149,71 @@ describe('PlaylistCard', () => { expect(getPlaylist).toHaveBeenCalledWith('p-1'); expect(playQueue).toHaveBeenCalled(); }); + + test('shows kebab button only on Discover playlists', () => { + const discoverPlaylist: Playlist = { + ...base, + kind: 'system', + system_variant: 'discover', + name: 'Discover' + }; + render(PlaylistCard, { props: { playlist: discoverPlaylist } }); + expect(screen.getByLabelText(/playlist actions/i)).toBeInTheDocument(); + }); + + test('does not show kebab button on for_you playlists', () => { + const forYouPlaylist: Playlist = { + ...base, + kind: 'system', + system_variant: 'for_you', + name: 'For You' + }; + render(PlaylistCard, { props: { playlist: forYouPlaylist } }); + expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument(); + }); + + test('does not show kebab button on user playlists', () => { + render(PlaylistCard, { props: { playlist: base } }); + expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument(); + }); + + test('Refresh Discover item appears in kebab menu when opened', async () => { + const discoverPlaylist: Playlist = { + ...base, + kind: 'system', + system_variant: 'discover', + name: 'Discover' + }; + render(PlaylistCard, { props: { playlist: discoverPlaylist } }); + const kebabBtn = screen.getByLabelText(/playlist actions/i); + await fireEvent.click(kebabBtn); + expect(screen.getByRole('menuitem', { name: /refresh discover/i })).toBeInTheDocument(); + }); + + test('Refresh Discover item is absent from for_you card kebab', () => { + const forYouPlaylist: Playlist = { + ...base, + kind: 'system', + system_variant: 'for_you', + name: 'For You' + }; + render(PlaylistCard, { props: { playlist: forYouPlaylist } }); + expect(screen.queryByRole('menuitem', { name: /refresh discover/i })).not.toBeInTheDocument(); + }); + + test('clicking Refresh Discover calls refreshDiscover', async () => { + const { refreshDiscover } = await import('$lib/api/playlists'); + const discoverPlaylist: Playlist = { + ...base, + kind: 'system', + system_variant: 'discover', + name: 'Discover' + }; + render(PlaylistCard, { props: { playlist: discoverPlaylist } }); + const kebabBtn = screen.getByLabelText(/playlist actions/i); + await fireEvent.click(kebabBtn); + const refreshItem = screen.getByRole('menuitem', { name: /refresh discover/i }); + await fireEvent.click(refreshItem); + await waitFor(() => expect(refreshDiscover).toHaveBeenCalled()); + }); }); diff --git a/web/src/routes/playlists/[id]/+page.svelte b/web/src/routes/playlists/[id]/+page.svelte index b169d1a8..713e1bcd 100644 --- a/web/src/routes/playlists/[id]/+page.svelte +++ b/web/src/routes/playlists/[id]/+page.svelte @@ -11,7 +11,8 @@ updatePlaylist, deletePlaylist, removePlaylistTrack, - reorderPlaylist + reorderPlaylist, + refreshDiscover } from '$lib/api/playlists'; import { qk } from '$lib/api/queries'; import { user } from '$lib/auth/store.svelte'; @@ -135,12 +136,49 @@ alert(copyForCode(code)); } } + + // --- Toast (detail page) --- + let toast = $state(null); + let toastTimer: ReturnType | null = null; + + function showToast(msg: string) { + if (toastTimer) clearTimeout(toastTimer); + toast = msg; + toastTimer = setTimeout(() => { toast = null; }, 5000); + } + + // --- Discover refresh --- + let refreshingDiscover = $state(false); + + async function onRefreshDiscover() { + refreshingDiscover = true; + try { + await refreshDiscover(); + showToast('Discover refreshed.'); + await queryClient.invalidateQueries({ queryKey: qk.playlist(id) }); + await queryClient.invalidateQueries({ queryKey: qk.playlists() }); + } catch (e: unknown) { + showToast(`Refresh failed: ${(e as { code?: string })?.code ?? 'unknown'}`); + } finally { + refreshingDiscover = false; + } + } {pageTitle(playlistQuery?.data?.name ? `Playlist · ${playlistQuery.data.name}` : 'Playlist')} +{#if toast} +
+ {toast} +
+{/if} +
{#if playlistQuery?.isPending}

Loading…

@@ -166,6 +204,17 @@ {#if !isOwner}· by {pl.owner_username}{/if}

+ {#if pl.system_variant === 'discover'} + + {/if} {#if isOwner}