feat(api): GET /api/collections/{id}/items (paged) + confirm POST-items 422 guard (#155)
Adds a paged collection-items endpoint reusing LibraryBrowseItemResponseModel
so the SPA lists a manual collection's full contents (all media kinds), replacing
the lossy Lucene name-based preview. Confirms POST /items already returns 422 for
bogus ids (guarded by ValidateMediaItems, fb3f2856); adds endpoint-level coverage.
fixes #155
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
deleteCollection,
|
||||
deleteSmartCollection,
|
||||
emptyAddItemsRequest,
|
||||
getCollectionItemsPreview,
|
||||
getCollectionItems,
|
||||
getCollections,
|
||||
getSmartCollections,
|
||||
removeItemFromCollection,
|
||||
@@ -158,23 +158,31 @@ describe('collections api client', () => {
|
||||
expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' });
|
||||
});
|
||||
|
||||
it('getCollectionItemsPreview issues a quoted collection: Lucene query', async () => {
|
||||
it('getCollectionItems requests the paged collection-items endpoint', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ totalCount: 1, page: [browseItem(5, 'Movie')] }));
|
||||
|
||||
const result = await getCollectionItems(7, 2, 50);
|
||||
|
||||
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
||||
expect(url.pathname).toBe('/api/collections/7/items');
|
||||
expect(url.searchParams.get('pageNum')).toBe('2');
|
||||
expect(url.searchParams.get('pageSize')).toBe('50');
|
||||
expect(result.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('getCollectionItems defaults to the first page of 100', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
|
||||
|
||||
await getCollectionItemsPreview('The Office');
|
||||
await getCollectionItems(9);
|
||||
|
||||
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
||||
expect(url.pathname).toBe('/api/library/browse');
|
||||
expect(url.searchParams.get('query')).toBe('collection:"The Office"');
|
||||
});
|
||||
|
||||
it('getCollectionItemsPreview returns [] for a blank name without calling the API', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
|
||||
|
||||
await expect(getCollectionItemsPreview(' ')).resolves.toEqual([]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(url.pathname).toBe('/api/collections/9/items');
|
||||
expect(url.searchParams.get('pageNum')).toBe('0');
|
||||
expect(url.searchParams.get('pageSize')).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-17
@@ -1,6 +1,6 @@
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
import { getLibraryBrowseItems, type LibraryBrowseItem } from './libraryBrowse';
|
||||
import type { LibraryBrowseItem, PagedLibraryBrowseItems } from './libraryBrowse';
|
||||
|
||||
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
|
||||
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
|
||||
@@ -40,6 +40,20 @@ export function removeItemFromCollection(id: number, mediaItemId: number): Promi
|
||||
return request<void>(`/api/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Lists a manual collection's full contents (all media kinds), paged. Backed by
|
||||
// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape.
|
||||
export function getCollectionItems(
|
||||
id: number,
|
||||
pageNum = 0,
|
||||
pageSize = 100
|
||||
): Promise<PagedLibraryBrowseItems> {
|
||||
const params = new URLSearchParams({
|
||||
pageNum: String(pageNum),
|
||||
pageSize: String(pageSize)
|
||||
});
|
||||
return request<PagedLibraryBrowseItems>(`/api/collections/${id}/items?${params.toString()}`);
|
||||
}
|
||||
|
||||
/* ---------- smart collections ---------- */
|
||||
|
||||
export function getSmartCollections(): Promise<SmartCollection[]> {
|
||||
@@ -116,22 +130,6 @@ export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollect
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
// Best-effort partial listing of a manual collection's members. No API returns a manual
|
||||
// collection's items by id; the only path is the Lucene `collection:"name"` search field
|
||||
// via library-browse, which covers Movie / Show / Season / Artist only. Callers must treat
|
||||
// this as an incomplete preview, never as the authoritative contents.
|
||||
export async function getCollectionItemsPreview(name: string): Promise<LibraryBrowseItem[]> {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const escaped = trimmed.replace(/"/g, '\\"');
|
||||
const result = await getLibraryBrowseItems({ pageSize: 100, query: `collection:"${escaped}"` });
|
||||
return result.page ?? [];
|
||||
}
|
||||
|
||||
export function messageFromCollectionError(error: unknown, fallback = 'Unable to load collections'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
|
||||
Reference in New Issue
Block a user