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.
This commit is contained in:
@@ -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<Response> = {}) {
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ListPlaylistsResponse> {
|
||||||
|
return (await apiFetch('/api/playlists', { method: 'GET' })) as ListPlaylistsResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlaylist(id: string): Promise<PlaylistDetail> {
|
||||||
|
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<Playlist> {
|
||||||
|
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<Playlist> {
|
||||||
|
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<void> {
|
||||||
|
await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendTracks(playlistID: string, trackIDs: string[]): Promise<PlaylistDetail> {
|
||||||
|
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<PlaylistDetail> {
|
||||||
|
return (await apiFetch(
|
||||||
|
`/api/playlists/${encodeURIComponent(playlistID)}/tracks/${position}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)) as PlaylistDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderPlaylist(playlistID: string, orderedPositions: number[]): Promise<PlaylistDetail> {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ export const qk = {
|
|||||||
home: () => ['home'] as const,
|
home: () => ['home'] as const,
|
||||||
albumsAlpha: () => ['albumsAlpha'] as const,
|
albumsAlpha: () => ['albumsAlpha'] as const,
|
||||||
artistTracks: (artistId: string) => ['artistTracks', artistId] 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) {
|
export function createArtistsQuery(sort: ArtistSort) {
|
||||||
|
|||||||
@@ -50,6 +50,37 @@ export type AlbumDetail = AlbumRef & {
|
|||||||
tracks: TrackRef[];
|
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 = {
|
export type SearchResponse = {
|
||||||
artists: Page<ArtistRef>;
|
artists: Page<ArtistRef>;
|
||||||
albums: Page<AlbumRef>;
|
albums: Page<AlbumRef>;
|
||||||
|
|||||||
Reference in New Issue
Block a user