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:
2026-07-08 18:26:52 +02:00
co-authored by Claude Opus 4.8
parent 50ae0a7f3b
commit f869dfe87a
14 changed files with 1045 additions and 666 deletions
+20 -12
View File
@@ -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
View File
@@ -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;