diff --git a/web/src/api/libraryBrowse.test.ts b/web/src/api/libraryBrowse.test.ts new file mode 100644 index 000000000..d28e4c595 --- /dev/null +++ b/web/src/api/libraryBrowse.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getLibraryBrowseItems } from './libraryBrowse'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json' }, + status + }); +} + +function browseUrl(fetchMock: ReturnType): URL { + return new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost'); +} + +describe('getLibraryBrowseItems', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('maps the paging/library/mediaType params into the query string', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); + + await getLibraryBrowseItems({ + query: 'star', + libraryId: 5, + mediaType: 'TelevisionShow', + pageNum: 2, + pageSize: 25 + }); + + const url = browseUrl(fetchMock); + expect(url.pathname).toBe('/api/library/browse'); + expect(url.searchParams.get('query')).toBe('star'); + expect(url.searchParams.get('libraryId')).toBe('5'); + expect(url.searchParams.get('mediaType')).toBe('TelevisionShow'); + expect(url.searchParams.get('pageNum')).toBe('2'); + expect(url.searchParams.get('pageSize')).toBe('25'); + expect(url.searchParams.has('parentId')).toBe(false); + }); + + it('sends parentId to scope seasons to a specific show', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); + + await getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: 42, pageSize: 100 }); + + const url = browseUrl(fetchMock); + expect(url.searchParams.get('mediaType')).toBe('TelevisionSeason'); + expect(url.searchParams.get('parentId')).toBe('42'); + }); + + it('omits parentId when it is not provided', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); + + await getLibraryBrowseItems({ mediaType: 'Movie' }); + + expect(browseUrl(fetchMock).searchParams.has('parentId')).toBe(false); + }); +}); diff --git a/web/src/api/libraryBrowse.ts b/web/src/api/libraryBrowse.ts index 75d4d50ec..b466b91e9 100644 --- a/web/src/api/libraryBrowse.ts +++ b/web/src/api/libraryBrowse.ts @@ -10,6 +10,8 @@ export interface GetLibraryBrowseItemsParams { mediaType?: LibraryBrowseMediaType; pageNum?: number; pageSize?: number; + // Only meaningful with mediaType 'TelevisionSeason': filters seasons to the given show id. + parentId?: number; query?: string; } @@ -36,6 +38,10 @@ export function getLibraryBrowseItems(params: GetLibraryBrowseItemsParams = {}): searchParams.set('pageSize', String(params.pageSize)); } + if (params.parentId != null) { + searchParams.set('parentId', String(params.parentId)); + } + const queryString = searchParams.toString(); return request(`/api/library/browse${queryString ? `?${queryString}` : ''}`); diff --git a/web/src/builder/ChannelBuilder.test.tsx b/web/src/builder/ChannelBuilder.test.tsx new file mode 100644 index 000000000..4e75306d8 --- /dev/null +++ b/web/src/builder/ChannelBuilder.test.tsx @@ -0,0 +1,64 @@ +import { cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChannelBuilderScreen } from './ChannelBuilder'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json' }, + status + }); +} + +// Mocks every endpoint the builder loads on mount so it can render its library +// browser, plus /api/library/browse which the browse hook fans out across. +function mockBuilderApi() { + return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.startsWith('/api/library/browse')) { + return Promise.resolve(jsonResponse({ page: [], totalCount: 0 })); + } + + if (url === '/api/channel-templates/default') { + // Builder treats a 404 here as "no default template". + return Promise.resolve(jsonResponse({ status: 404, title: 'Not Found' }, 404)); + } + + // channels, channel-templates, ffmpeg/profiles, filler-presets, watermarks, media-sources + return Promise.resolve(jsonResponse([])); + }); +} + +function browseMediaTypes(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .map(([u]) => u.toString()) + .filter((u) => u.startsWith('/api/library/browse')) + .map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType')) + .filter((mediaType): mediaType is string => mediaType != null && mediaType !== ''); +} + +describe('ChannelBuilder library browse', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + beforeEach(() => { + window.localStorage.clear(); + }); + + it('fans out over movies/shows/artists and never requests TelevisionSeason', async () => { + const fetchMock = mockBuilderApi(); + + render(); + + // Wait until the initial library browse fan-out has fired. + await waitFor(() => { + expect(browseMediaTypes(fetchMock).length).toBeGreaterThan(0); + }); + + const types = new Set(browseMediaTypes(fetchMock)); + expect(types).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist'])); + expect(types.has('TelevisionSeason')).toBe(false); + }); +}); diff --git a/web/src/builder/ChannelBuilder.tsx b/web/src/builder/ChannelBuilder.tsx index edaa49ecd..aad213d87 100644 --- a/web/src/builder/ChannelBuilder.tsx +++ b/web/src/builder/ChannelBuilder.tsx @@ -140,14 +140,15 @@ const COLLECTION_MEDIA_TYPES: LibraryBrowseMediaType[] = [ 'Playlist' ]; -// The pre-#168 API default: browsing a library shows the "pickable" top-level -// kinds only (shows/seasons/movies/artists), not every episode/song/etc. -// nested underneath them. Kept explicit here since `GET /api/library/browse` -// now spans all 10 media kinds when `mediaType` is omitted. +// Browsing a library shows the "pickable" top-level kinds only, not every +// episode/song/etc. nested underneath them. Kept explicit here since +// `GET /api/library/browse` now spans all 10 media kinds when `mediaType` is +// omitted. TelevisionSeason is intentionally excluded so a multi-season show +// renders as a single tile instead of flooding the grid with per-season tiles +// (issue #180); seasons are reachable via the show tile's Seasons drill-in. const LIBRARY_MEDIA_TYPES: LibraryBrowseMediaType[] = [ 'Movie', 'TelevisionShow', - 'TelevisionSeason', 'Artist' ]; @@ -326,6 +327,7 @@ function LibCard({ added, compact, onAdd, + onSeasons, onDragStart, onDragEnd }: { @@ -333,6 +335,7 @@ function LibCard({ added: boolean; compact: boolean; onAdd: () => void; + onSeasons?: () => void; onDragStart: () => void; onDragEnd: () => void; }) { @@ -369,6 +372,18 @@ function LibCard({ )} + {onSeasons && ( + { + event.stopPropagation(); + onSeasons(); + }} + > + + )} {added ? (