feat(spa): collection custom-order reorder UI + all-kind add picker (#211)

Adds PUT /api/collections/{id}/custom-order support (updateCollectionCustomOrder)
and a reorder mode in ManualItemsView: loads every page of a manual collection
(so the wholesale-replace PUT never drops items), lets the user move items with
up/down icon buttons, and saves/cancels. Reorder is offered for any manual
collection with useCustomPlaybackOrder on, not just movies-only (server/enumerator
already support any kind).

Widens the add-items picker (ADDABLE_TYPE_LIST, MEDIA_KIND_FILTERS,
toAddItemsRequest) from 4 to all 10 addable media kinds; the default "All" search
fan-out stays Movie/Show/Artist (seasons excluded per #180), with the new kinds
reachable via their specific filter, mirroring Blazor's per-kind list pages.
This commit is contained in:
2026-07-09 22:35:43 +02:00
parent 91e23bcd57
commit 606d50bb8d
4 changed files with 411 additions and 29 deletions
+31 -1
View File
@@ -12,6 +12,7 @@ import {
removeItemFromCollection,
toAddItemsRequest,
updateCollection,
updateCollectionCustomOrder,
updateSmartCollection
} from './collections';
import type { LibraryBrowseItem } from './libraryBrowse';
@@ -184,6 +185,17 @@ describe('collections api client', () => {
expect(url.searchParams.get('pageNum')).toBe('0');
expect(url.searchParams.get('pageSize')).toBe('100');
});
it('updateCollectionCustomOrder PUTs the full ordered mediaItemIds array', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await updateCollectionCustomOrder(7, [30, 10, 20]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/collections/7/custom-order');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toEqual({ mediaItemIds: [30, 10, 20] });
});
});
describe('toAddItemsRequest bucket mapping', () => {
@@ -202,6 +214,24 @@ describe('toAddItemsRequest bucket mapping', () => {
expect(result.artistIds).toEqual([40]);
});
it('routes the remaining six addable kinds into their own buckets (#211)', () => {
const result = toAddItemsRequest([
browseItem(50, 'Episode'),
browseItem(51, 'MusicVideo'),
browseItem(52, 'Song'),
browseItem(53, 'OtherVideo'),
browseItem(54, 'Image'),
browseItem(55, 'RemoteStream')
]);
expect(result.episodeIds).toEqual([50]);
expect(result.musicVideoIds).toEqual([51]);
expect(result.songIds).toEqual([52]);
expect(result.otherVideoIds).toEqual([53]);
expect(result.imageIds).toEqual([54]);
expect(result.remoteStreamIds).toEqual([55]);
});
it('skips kinds that are not addable media items (collections, playlists, etc.)', () => {
const result = toAddItemsRequest([
browseItem(1, 'Collection'),
@@ -218,7 +248,7 @@ describe('toAddItemsRequest bucket mapping', () => {
expect(result.artistIds).toEqual([]);
});
it('emptyAddItemsRequest leaves the picker-unreachable buckets empty', () => {
it('emptyAddItemsRequest starts every bucket empty', () => {
const empty = emptyAddItemsRequest();
expect(empty.episodeIds).toEqual([]);
+39 -11
View File
@@ -7,6 +7,7 @@ export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type CreateCollectionRequest = components['schemas']['CreateCollectionRequest'];
export type UpdateCollectionRequest = components['schemas']['UpdateCollectionRequest'];
export type AddItemsToCollectionRequest = components['schemas']['AddItemsToCollectionRequest'];
export type UpdateCollectionCustomOrderRequest = components['schemas']['UpdateCollectionCustomOrderRequest'];
export type CreateSmartCollectionRequest = components['schemas']['CreateSmartCollectionRequest'];
export type UpdateSmartCollectionRequest = components['schemas']['UpdateSmartCollectionRequest'];
@@ -41,7 +42,9 @@ export function removeItemFromCollection(id: number, mediaItemId: number): Promi
}
// 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.
// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape. When the
// collection's useCustomPlaybackOrder is true, items come back ordered by CustomIndex (nulls
// last, then title); otherwise title order.
export function getCollectionItems(
id: number,
pageNum = 0,
@@ -54,6 +57,14 @@ export function getCollectionItems(
return request<PagedLibraryBrowseItems>(`/api/collections/${id}/items?${params.toString()}`);
}
// Replaces a manual collection's custom order wholesale: the CustomIndex of each media item is
// derived from its position in `mediaItemIds`, so a partial array silently drops the items left
// out of it (#211). Callers must submit the full ordered id list.
export function updateCollectionCustomOrder(id: number, mediaItemIds: number[]): Promise<void> {
const body: UpdateCollectionCustomOrderRequest = { mediaItemIds };
return request<void>(`/api/collections/${id}/custom-order`, { body, method: 'PUT' });
}
/* ---------- smart collections ---------- */
export function getSmartCollections(): Promise<SmartCollection[]> {
@@ -78,11 +89,11 @@ export function deleteSmartCollection(id: number): Promise<void> {
/* ---------- add-items bucket mapping ---------- */
// The add-items request buckets media-item ids by kind. The library-browse search only
// surfaces four kinds (Movie / TelevisionShow / TelevisionSeason / Artist), so those are
// the only buckets reachable from the picker. The remaining buckets (episodes, music
// videos, songs, images, other videos, remote streams) can't be produced by browse and
// are left empty here. See CollectionsScreen for the honest note about this limit.
// The add-items request buckets media-item ids by kind. All 10 addable kinds (Movie /
// TelevisionShow / TelevisionSeason / Artist / Episode / MusicVideo / Song / OtherVideo /
// Image / RemoteStream) are reachable from the picker via `toAddItemsRequest` below — the
// default "All" fan-out only searches Movie/TelevisionShow/Artist (see CollectionsScreen's
// DEFAULT_SEARCH_KINDS), but every kind can be found by picking its specific filter.
export function emptyAddItemsRequest(): AddItemsToCollectionRequest {
return {
artistIds: [],
@@ -98,11 +109,10 @@ export function emptyAddItemsRequest(): AddItemsToCollectionRequest {
};
}
// Buckets a set of browse results into an AddItemsToCollectionRequest. Movie / Show /
// Season / Artist are the only kinds library-browse can return as concrete media; each
// carries its media-item id in `id` (which equals `mediaItemId` for these types, since
// they are all MediaItem subclasses). Ids must go into their type-specific bucket, since
// the server validates each bucket against that entity type (a Show id in movieIds fails
// Buckets a set of browse results into an AddItemsToCollectionRequest. Each addable kind
// carries its media-item id in `id` (which equals `mediaItemId` for these types, since they
// are all MediaItem subclasses). Ids must go into their type-specific bucket, since the
// server validates each bucket against that entity type (a Show id in movieIds fails
// validation). Any other kind (collections, smart/multi/rerun collections, playlists) is
// not an addable media item and is skipped.
export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollectionRequest {
@@ -122,6 +132,24 @@ export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollect
case 'Artist':
requestBody.artistIds?.push(item.id);
break;
case 'Episode':
requestBody.episodeIds?.push(item.id);
break;
case 'MusicVideo':
requestBody.musicVideoIds?.push(item.id);
break;
case 'Song':
requestBody.songIds?.push(item.id);
break;
case 'OtherVideo':
requestBody.otherVideoIds?.push(item.id);
break;
case 'Image':
requestBody.imageIds?.push(item.id);
break;
case 'RemoteStream':
requestBody.remoteStreamIds?.push(item.id);
break;
default:
break;
}