From b9830cc9cc38b1f44c230c204b5ecdc355cb7bff Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:12:46 -0400 Subject: [PATCH] feat(web/api): playlists helper + types + query keys (M7 #352 slice 1) Wire shapes mirror the server's snake_case envelope. URL helpers use the existing apiFetch + ApiError surface so error codes (not_found, not_authorized, bad_request, server_error) propagate via the same copyForCode chain as everything else. --- web/src/lib/api/playlists.test.ts | 67 +++++++++++++++++++++++ web/src/lib/api/playlists.ts | 90 +++++++++++++++++++++++++++++++ web/src/lib/api/queries.ts | 2 + web/src/lib/api/types.ts | 31 +++++++++++ 4 files changed, 190 insertions(+) create mode 100644 web/src/lib/api/playlists.test.ts create mode 100644 web/src/lib/api/playlists.ts diff --git a/web/src/lib/api/playlists.test.ts b/web/src/lib/api/playlists.test.ts new file mode 100644 index 00000000..209f9ae8 --- /dev/null +++ b/web/src/lib/api/playlists.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + listPlaylists, + getPlaylist, + createPlaylist, + reorderPlaylist, + removePlaylistTrack +} from './playlists'; + +function stubFetch(status: number, body: unknown, init: Partial = {}) { + const res = new Response( + body === null ? null : JSON.stringify(body), + { status, headers: { 'Content-Type': 'application/json' }, ...init } + ); + const spy = vi.fn().mockResolvedValue(res); + vi.stubGlobal('fetch', spy); + return spy; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('playlists API helper', () => { + test('listPlaylists GETs /api/playlists', async () => { + const spy = stubFetch(200, { owned: [], public: [] }); + const r = await listPlaylists(); + expect(r.owned).toEqual([]); + expect(r.public).toEqual([]); + const call = spy.mock.calls[0]; + expect(call[0]).toBe('/api/playlists'); + expect((call[1] as RequestInit).method).toBe('GET'); + }); + + test('createPlaylist POSTs JSON body', async () => { + const spy = stubFetch(200, { id: 'p1', name: 'Test' }); + const r = await createPlaylist({ name: 'Test' }); + expect(r.id).toBe('p1'); + const call = spy.mock.calls[0]; + expect((call[1] as RequestInit).method).toBe('POST'); + expect(JSON.parse((call[1] as RequestInit).body as string)).toEqual({ name: 'Test' }); + }); + + test('reorderPlaylist PUTs ordered_positions', async () => { + const spy = stubFetch(200, { id: 'p1', tracks: [] }); + await reorderPlaylist('p1', [2, 1, 0]); + const call = spy.mock.calls[0]; + expect(call[0]).toBe('/api/playlists/p1/tracks'); + expect((call[1] as RequestInit).method).toBe('PUT'); + expect(JSON.parse((call[1] as RequestInit).body as string)).toEqual({ + ordered_positions: [2, 1, 0] + }); + }); + + test('removePlaylistTrack DELETEs by position', async () => { + const spy = stubFetch(200, { id: 'p1', tracks: [] }); + await removePlaylistTrack('p1', 3); + const call = spy.mock.calls[0]; + expect(call[0]).toBe('/api/playlists/p1/tracks/3'); + expect((call[1] as RequestInit).method).toBe('DELETE'); + }); + + test('not_found surfaces as ApiError', async () => { + stubFetch(404, { error: 'not_found' }); + await expect(getPlaylist('missing')).rejects.toMatchObject({ code: 'not_found' }); + }); +}); diff --git a/web/src/lib/api/playlists.ts b/web/src/lib/api/playlists.ts new file mode 100644 index 00000000..9e0ba763 --- /dev/null +++ b/web/src/lib/api/playlists.ts @@ -0,0 +1,90 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { apiFetch } from './client'; +import { qk } from './queries'; +import type { Playlist, PlaylistDetail } from './types'; + +export type ListPlaylistsResponse = { + owned: Playlist[]; + public: Playlist[]; +}; + +export async function listPlaylists(): Promise { + return (await apiFetch('/api/playlists', { method: 'GET' })) as ListPlaylistsResponse; +} + +export async function getPlaylist(id: string): Promise { + return (await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { + method: 'GET' + })) as PlaylistDetail; +} + +export type CreatePlaylistInput = { + name: string; + description?: string; + is_public?: boolean; +}; + +export async function createPlaylist(input: CreatePlaylistInput): Promise { + return (await apiFetch('/api/playlists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input) + })) as Playlist; +} + +export type UpdatePlaylistInput = { + name?: string; + description?: string; + is_public?: boolean; +}; + +export async function updatePlaylist(id: string, input: UpdatePlaylistInput): Promise { + return (await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input) + })) as Playlist; +} + +export async function deletePlaylist(id: string): Promise { + await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export async function appendTracks(playlistID: string, trackIDs: string[]): Promise { + return (await apiFetch(`/api/playlists/${encodeURIComponent(playlistID)}/tracks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIDs }) + })) as PlaylistDetail; +} + +export async function removePlaylistTrack(playlistID: string, position: number): Promise { + return (await apiFetch( + `/api/playlists/${encodeURIComponent(playlistID)}/tracks/${position}`, + { method: 'DELETE' } + )) as PlaylistDetail; +} + +export async function reorderPlaylist(playlistID: string, orderedPositions: number[]): Promise { + return (await apiFetch(`/api/playlists/${encodeURIComponent(playlistID)}/tracks`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ordered_positions: orderedPositions }) + })) as PlaylistDetail; +} + +export function createPlaylistsQuery() { + return createQuery({ + queryKey: qk.playlists(), + queryFn: listPlaylists, + staleTime: 30_000 + }); +} + +export function createPlaylistQuery(id: string) { + return createQuery({ + queryKey: qk.playlist(id), + queryFn: () => getPlaylist(id), + staleTime: 30_000 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index b6991e5d..08d03597 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -38,6 +38,8 @@ export const qk = { home: () => ['home'] as const, albumsAlpha: () => ['albumsAlpha'] as const, artistTracks: (artistId: string) => ['artistTracks', artistId] as const, + playlists: () => ['playlists'] as const, + playlist: (id: string) => ['playlist', id] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index db76065b..2434bdf2 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -50,6 +50,37 @@ export type AlbumDetail = AlbumRef & { tracks: TrackRef[]; }; +export type Playlist = { + id: string; + user_id: string; + owner_username: string; + name: string; + description: string; + is_public: boolean; + cover_url: string; // empty string when no cover; UI renders glyph + track_count: number; + duration_sec: number; + created_at: string; + updated_at: string; +}; + +export type PlaylistTrack = { + position: number; + track_id: string | null; // null when upstream track was removed + album_id: string | null; + artist_id: string | null; + title: string; + artist_name: string; + album_title: string; + duration_sec: number; + stream_url: string | null; + added_at: string; +}; + +export type PlaylistDetail = Playlist & { + tracks: PlaylistTrack[]; +}; + export type SearchResponse = { artists: Page; albums: Page;