Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user