feat(web): media browse, global search & trash screens (#141)

- MediaBrowseScreen (/app/media): one parameterized screen for all 9 top-level
  kinds with an in-screen kind switcher, per-kind search box, 100/page paging
- SearchScreen (/app/search): grouped per-kind results with counts + 'See all';
  TopBar search input now navigates here
- TrashScreen (/app/trash): state:FileNotFound results, multi-select + Empty Trash
  via DELETE /api/media-items and /api/maintenance/empty_trash (confirm dialogs)
- Shared media/mediaKinds (icon/label maps, hueOf, duration helpers) + MediaPosterCard;
  ChannelBuilder and CollectionsScreen now reuse the single-source maps (their
  exhaustive Record<LibraryBrowseMediaType> had to cover the 6 new kinds anyway)
- api: search.ts, mediaItems.ts, maintenance.ts (+ URL-assert tests)
- One consolidated 'Browse' nav entry with an in-screen kind switcher instead of
  9 per-kind nav rows (deviation from Blazor's per-kind Media links)
- Nav: Media group gains Browse/Search/Trash; Settings Classic-UI help trimmed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 15:39:54 +02:00
co-authored by Claude Fable 5
parent 6c68c291a0
commit 14cafca740
17 changed files with 1178 additions and 54 deletions
+3
View File
@@ -11,9 +11,12 @@ export * from './guide';
export * from './libraries';
export * from './libraryBrowse';
export * from './logs';
export * from './maintenance';
export * from './mediaItems';
export * from './pickers';
export * from './playouts';
export * from './schedules';
export * from './search';
export * from './settings';
export * from './troubleshoot';
export * from './useChannelsQuery';
+25
View File
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { emptyTrash } from './maintenance';
describe('emptyTrash', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('POSTs /api/maintenance/empty_trash', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
await emptyTrash();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/maintenance/empty_trash');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 500 }));
await expect(emptyTrash()).rejects.toMatchObject({ status: 500 });
});
});
+17
View File
@@ -0,0 +1,17 @@
import { ApiError, request } from './client';
export function emptyTrash(): Promise<void> {
return request<void>('/api/maintenance/empty_trash', { method: 'POST' });
}
export function messageFromMaintenanceError(error: unknown, fallback = 'Maintenance action failed'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deleteMediaItems } from './mediaItems';
describe('deleteMediaItems', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('DELETEs /api/media-items with the ids in the body', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await deleteMediaItems([1, 2, 3]);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/media-items');
expect((init?.method ?? 'GET').toUpperCase()).toBe('DELETE');
expect(JSON.parse(init?.body as string)).toEqual({ ids: [1, 2, 3] });
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 422, title: 'Validation failed' }), {
headers: { 'Content-Type': 'application/json' },
status: 422
})
);
await expect(deleteMediaItems([])).rejects.toMatchObject({ status: 422 });
});
});
+17
View File
@@ -0,0 +1,17 @@
import { ApiError, request } from './client';
export function deleteMediaItems(ids: number[]): Promise<void> {
return request<void>('/api/media-items', { body: { ids }, method: 'DELETE' });
}
export function messageFromMediaItemsError(error: unknown, fallback = 'Unable to delete media items'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getSearchResults } from './search';
const emptyGroup = { totalCount: 0, items: [] };
const sampleResults = {
movies: emptyGroup,
shows: emptyGroup,
seasons: emptyGroup,
artists: emptyGroup,
episodes: emptyGroup,
musicVideos: emptyGroup,
songs: emptyGroup,
otherVideos: emptyGroup,
images: emptyGroup,
remoteStreams: emptyGroup
};
describe('getSearchResults', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/search with the query', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResults), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await getSearchResults({ query: 'star' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/search?query=star');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
it('includes pageSize when provided', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResults), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await getSearchResults({ query: 'star wars', pageSize: 50 });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/search?query=star+wars&pageSize=50');
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 422, title: 'Validation failed' }), {
headers: { 'Content-Type': 'application/json' },
status: 422
})
);
await expect(getSearchResults({ query: '' })).rejects.toMatchObject({ status: 422 });
});
});
+33
View File
@@ -0,0 +1,33 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type SearchResults = components['schemas']['SearchResultsResponseModel'];
export type SearchResultGroup = components['schemas']['SearchResultGroupResponseModel'];
export interface GetSearchResultsParams {
query: string;
pageSize?: number;
}
export function getSearchResults(params: GetSearchResultsParams): Promise<SearchResults> {
const searchParams = new URLSearchParams();
searchParams.set('query', params.query);
if (params.pageSize != null) {
searchParams.set('pageSize', String(params.pageSize));
}
return request<SearchResults>(`/api/search?${searchParams.toString()}`);
}
export function messageFromSearchError(error: unknown, fallback = 'Unable to search library'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}