feat(spa): shared Add-to dialogs + MediaPosterCard actions slot (#208 #209)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 00:00:05 +02:00
co-authored by Claude Fable 5
parent cfabd6f33f
commit 1e72faa60f
19 changed files with 1527 additions and 3 deletions
+40
View File
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { scanLibrary, scanShow } from './libraries';
function noContent(): Response {
return new Response(null, { status: 200 });
}
function lastCall(fetchMock: ReturnType<typeof vi.spyOn>) {
const call = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
return { init: call[1] as RequestInit | undefined, url: String(call[0]) };
}
describe('libraries api client', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('scanLibrary POSTs to the library scan endpoint', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanLibrary(4);
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan', expect.objectContaining({ method: 'POST' }));
});
it('scanShow POSTs the show title and deepScan flag', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanShow(4, { deepScan: true, showTitle: 'The Office' });
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/libraries/4/scan-show');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: true, showTitle: 'The Office' });
});
it('scanShow defaults deepScan to false when omitted', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanShow(9, { showTitle: 'Firefly' });
const { init } = lastCall(fetchMock);
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: false, showTitle: 'Firefly' });
});
});
+15
View File
@@ -51,6 +51,21 @@ export function scanLibrary(libraryId: number): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
}
export interface ScanShowParams {
showTitle: string;
deepScan?: boolean;
}
// Queues a scan of a single show (by title) within a library. Returns 200 on success, 400 when
// the title can't be resolved / the library doesn't support single-show scanning. Body keys are
// `showTitle` and `deepScan` (see LibrariesController.ScanShowRequest).
export function scanShow(libraryId: number, params: ScanShowParams): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan-show`, {
body: { deepScan: params.deepScan ?? false, showTitle: params.showTitle },
method: 'POST'
});
}
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
// mutates the grace-ticks map in place (delete on promote/expire, set on decrement) -
// callers must still write pendingIdsRef.current with the returned set themselves, and
+22
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
addItemsToPlaylist,
createPlaylist,
createPlaylistGroup,
deletePlaylist,
@@ -131,6 +132,27 @@ describe('playlists api client', () => {
expect(JSON.parse(String(init?.body))).toEqual({ items: [sampleItem], name: 'Draft' });
});
it('addItemsToPlaylist POSTs the bucketed ids to the items endpoint', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
const body = {
artistIds: [],
episodeIds: [],
imageIds: [],
movieIds: [12],
musicVideoIds: [],
otherVideoIds: [],
remoteStreamIds: [],
seasonIds: [],
showIds: [3],
songIds: []
};
await addItemsToPlaylist(6, body);
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/playlists/6/items');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual(body);
});
it('messageFromPlaylistError prefers ApiError detail', () => {
expect(messageFromPlaylistError(new ApiError(422, { detail: 'Name is required' }))).toBe('Name is required');
expect(messageFromPlaylistError('nope', 'fallback')).toBe('fallback');
+12
View File
@@ -1,5 +1,6 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
import type { AddItemsToCollectionRequest } from './collections';
export type PlaylistGroup = components['schemas']['PlaylistGroupResponseModel'];
export type Playlist = components['schemas']['PlaylistResponseModel'];
@@ -56,6 +57,17 @@ export function previewPlaylist(body: ReplacePlaylistRequest): Promise<PlaylistP
return request<PlaylistPreviewItem[]>('/api/playlists/preview', { body, method: 'POST' });
}
// Adds media items (bucketed by kind) to an existing playlist. The request body is the same
// ten-array shape as AddItemsToCollectionRequest (movieIds/showIds/seasonIds/episodeIds/
// artistIds/musicVideoIds/otherVideoIds/songIds/imageIds/remoteStreamIds); the server returns
// 204 on success, 404 for a missing playlist, 422 for validation failures.
// TODO(#208): switch to generated types after OpenAPI regen lands (endpoint added on a sibling
// backend branch, not yet in v1.d.ts). The body is structurally identical to the generated
// AddItemsToCollectionRequest, so we reuse that type here.
export function addItemsToPlaylist(playlistId: number, body: AddItemsToCollectionRequest): Promise<void> {
return request<void>(`/api/playlists/${playlistId}/items`, { body, method: 'POST' });
}
export function messageFromPlaylistError(error: unknown, fallback = 'Unable to load playlists'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+40 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getSearchResults } from './search';
import { getSearchAllItems, getSearchResults, toAddItemsRequestFromSearch } from './search';
const emptyGroup = { totalCount: 0, items: [] };
const sampleResults = {
@@ -61,3 +61,42 @@ describe('getSearchResults', () => {
await expect(getSearchResults({ query: '' })).rejects.toMatchObject({ status: 422 });
});
});
describe('getSearchAllItems', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/search/all-items with the query', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ movieIds: [1], showIds: [2] }), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await getSearchAllItems('star wars');
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/search/all-items?query=star+wars');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
});
describe('toAddItemsRequestFromSearch', () => {
it('fills every bucket, defaulting null/undefined arrays to []', () => {
expect(toAddItemsRequestFromSearch({ movieIds: [1, 2], showIds: null })).toEqual({
artistIds: [],
episodeIds: [],
imageIds: [],
movieIds: [1, 2],
musicVideoIds: [],
otherVideoIds: [],
remoteStreamIds: [],
seasonIds: [],
showIds: [],
songIds: []
});
});
});
+44
View File
@@ -1,9 +1,28 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
import type { AddItemsToCollectionRequest } from './collections';
export type SearchResults = components['schemas']['SearchResultsResponseModel'];
export type SearchResultGroup = components['schemas']['SearchResultGroupResponseModel'];
// The ten media-item id arrays a search query resolves to, one bucket per addable kind. Wire keys
// match AddItemsToCollectionRequest exactly, so a result pipes straight into addItemsToCollection /
// addItemsToPlaylist via toAddItemsRequestFromSearch below.
// TODO(#208): switch to generated types after OpenAPI regen lands (GET /api/search/all-items was
// added on a sibling backend branch, not yet in v1.d.ts). Locally typed until then.
export interface SearchAllItemIds {
artistIds?: null | number[];
episodeIds?: null | number[];
imageIds?: null | number[];
movieIds?: null | number[];
musicVideoIds?: null | number[];
otherVideoIds?: null | number[];
remoteStreamIds?: null | number[];
seasonIds?: null | number[];
showIds?: null | number[];
songIds?: null | number[];
}
export interface GetSearchResultsParams {
query: string;
pageSize?: number;
@@ -20,6 +39,31 @@ export function getSearchResults(params: GetSearchResultsParams): Promise<Search
return request<SearchResults>(`/api/search?${searchParams.toString()}`);
}
// Resolves a search query to the full set of matching media-item ids, bucketed by kind. Backs the
// "Add all results" flow so the caller never has to page through every result to add them.
export function getSearchAllItems(query: string): Promise<SearchAllItemIds> {
const searchParams = new URLSearchParams();
searchParams.set('query', query);
return request<SearchAllItemIds>(`/api/search/all-items?${searchParams.toString()}`);
}
// Normalizes a SearchAllItemIds result (nullable arrays) into a full AddItemsToCollectionRequest
// so it can be piped straight into addItemsToCollection / addItemsToPlaylist.
export function toAddItemsRequestFromSearch(result: SearchAllItemIds): AddItemsToCollectionRequest {
return {
artistIds: result.artistIds ?? [],
episodeIds: result.episodeIds ?? [],
imageIds: result.imageIds ?? [],
movieIds: result.movieIds ?? [],
musicVideoIds: result.musicVideoIds ?? [],
otherVideoIds: result.otherVideoIds ?? [],
remoteStreamIds: result.remoteStreamIds ?? [],
seasonIds: result.seasonIds ?? [],
showIds: result.showIds ?? [],
songIds: result.songIds ?? []
};
}
export function messageFromSearchError(error: unknown, fallback = 'Unable to search library'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;