diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md
index b4fe6893e..bb617695a 100644
--- a/docs/blazor-route-parity.md
+++ b/docs/blazor-route-parity.md
@@ -113,7 +113,7 @@ been added to the redirect map yet.
| `/media/movies/{MovieId:int}` | `Movie.razor` | `/app/media/movies/{id}` | detail page (`MovieDetailScreen`); PR #183 / #141 |
| `/media/tv/shows`(`/page/{n}`) | `TelevisionShowList.razor` | `/app/media?kind=shows` | generic browse; PR #183 / #141 |
| `/media/tv/shows/{ShowId:int}` | `TelevisionSeasonList.razor` | `/app/media/shows/{id}` | show + season list (`ShowDetailScreen`); PR #183 / #141 |
-| `/media/tv/seasons`(`/page/{n}`) | `TelevisionSeasonSearchResults.razor` | `/app/media?kind=shows` | no dedicated season-list SPA screen; covered via show drill-in; PR #183 / #141 |
+| `/media/tv/seasons`(`/page/{n}`) | `TelevisionSeasonSearchResults.razor` | `/app/media?kind=seasons` | seasons browsable as a top-level kind (`MediaBrowseScreen`; also reachable via show drill-in); #209 review fix |
| `/media/tv/seasons/{SeasonId:int}` | `TelevisionEpisodeList.razor` | `/app/media/seasons/{id}` | season + episode list (`SeasonDetailScreen`); PR #183 / #141 |
| `/media/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media/seasons/{id}` | no standalone SPA episode browse; covered via season detail drill-in; PR #183 / #141 |
| `/media/music/artists`(`/page/{n}`) | `ArtistList.razor` | `/app/media?kind=artists` | generic browse; PR #183 / #141 |
diff --git a/docs/decisions.md b/docs/decisions.md
index 8882c03d2..7dc4f6dec 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -216,7 +216,12 @@ don't build screen-local pickers. Two deliberate deviations from Blazor, applied
the search and browse screens: **(1) multi-select is an explicit screen-level "Select" toggle**
(off = cards open, on = cards select) rather than Blazor's always-on corner-select, because
`MediaPosterCard`'s select handler takes over the card's single click gesture; **(2) the
-per-card menu offers collection/playlist/schedule for a single item** — a superset of Blazor's
-per-card collection-only menu. "Add All" (query-wide) mirrors Blazor's two-step: materialize ids
+per-card menu offers collection/playlist for a single item of any kind, plus schedule only for
+shows/seasons/artists** — collection/playlist is a superset of Blazor's per-card collection-only
+menu, while the schedule target is gated to exactly the kinds Blazor's
+`AddProgramScheduleItem.ForMediaItem` call sites offer, because the server validator
+(`ProgramScheduleItemCommandBase.CollectionTypeMustBeValid`) accepts only the
+TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s the rest.
+"Add All" (query-wide) mirrors Blazor's two-step: materialize ids
via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add
command exists server-side. Issues #208/#209.
diff --git a/web/src/media/addTo/AddToMenu.test.tsx b/web/src/media/addTo/AddToMenu.test.tsx
index 09f0e4f2c..781858684 100644
--- a/web/src/media/addTo/AddToMenu.test.tsx
+++ b/web/src/media/addTo/AddToMenu.test.tsx
@@ -8,6 +8,7 @@ function jsonResponse(body: unknown, status = 200): Response {
}
const movieItem = { artwork: '', id: 12, mediaType: 'Movie', title: 'Blade Runner' } as unknown as LibraryBrowseItem;
+const showItem = { artwork: '', id: 42, mediaType: 'TelevisionShow', title: 'The Office' } as unknown as LibraryBrowseItem;
function mockApi() {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
@@ -35,9 +36,9 @@ describe('AddToMenu', () => {
cleanup();
});
- it('opens the menu and shows the requested targets', () => {
+ it('opens the menu and shows the requested targets for a schedulable kind', () => {
mockApi();
- render();
+ render();
fireEvent.click(screen.getByRole('button', { name: 'Add to…' }));
expect(screen.getByRole('menuitem', { name: /Add to collection/ })).toBeTruthy();
@@ -47,12 +48,35 @@ describe('AddToMenu', () => {
it('hides the schedule target when more than one item is selected', () => {
mockApi();
- render();
+ render();
fireEvent.click(screen.getByRole('button', { name: 'Add to…' }));
expect(screen.queryByRole('menuitem', { name: /Add to schedule/ })).toBeNull();
});
+ it('hides the schedule target for a non-schedulable kind (movie)', () => {
+ mockApi();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add to…' }));
+ expect(screen.getByRole('menuitem', { name: /Add to collection/ })).toBeTruthy();
+ expect(screen.getByRole('menuitem', { name: /Add to playlist/ })).toBeTruthy();
+ expect(screen.queryByRole('menuitem', { name: /Add to schedule/ })).toBeNull();
+ });
+
+ it('closes the menu on Escape and returns focus to the trigger', () => {
+ mockApi();
+ render();
+
+ const trigger = screen.getByRole('button', { name: 'Add to…' });
+ fireEvent.click(trigger);
+ expect(screen.getByRole('menu')).toBeTruthy();
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByRole('menu')).toBeNull();
+ expect(document.activeElement).toBe(trigger);
+ });
+
it('respects an explicit targets list', () => {
mockApi();
render();
diff --git a/web/src/media/addTo/AddToMenu.tsx b/web/src/media/addTo/AddToMenu.tsx
index 77f35fd7d..fc220c3d4 100644
--- a/web/src/media/addTo/AddToMenu.tsx
+++ b/web/src/media/addTo/AddToMenu.tsx
@@ -5,6 +5,7 @@ import type { LibraryBrowseItem } from '../../api';
import { AddToCollectionDialog } from './AddToCollectionDialog';
import { AddToPlaylistDialog } from './AddToPlaylistDialog';
import { AddToScheduleDialog } from './AddToScheduleDialog';
+import { collectionTypeForMediaType } from './scheduleItem';
export type AddToTarget = 'collection' | 'playlist' | 'schedule';
@@ -35,15 +36,26 @@ export function AddToMenu({ items, targets = DEFAULT_TARGETS, onDone, compact =
setMenuOpen(false);
}
};
+ // Escape closes the popover and returns focus to the trigger button.
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setMenuOpen(false);
+ rootRef.current?.querySelector(':scope > button')?.focus();
+ }
+ };
document.addEventListener('mousedown', onPointerDown);
+ document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
+ document.removeEventListener('keydown', onKeyDown);
};
}, [menuOpen]);
- // Schedule adds a single media item; only offer it when exactly one item is in play.
- const scheduleItem = items.length === 1 ? items[0] : null;
+ // Schedule adds a single media item, and the server only accepts shows / seasons / artists as
+ // per-media-item schedule items — only offer it when exactly one schedulable item is in play.
+ const scheduleItem =
+ items.length === 1 && collectionTypeForMediaType(items[0].mediaType) !== null ? items[0] : null;
const visibleTargets = targets.filter((target) => target !== 'schedule' || scheduleItem !== null);
const disabled = items.length === 0;
diff --git a/web/src/media/addTo/AddToPlaylistDialog.test.tsx b/web/src/media/addTo/AddToPlaylistDialog.test.tsx
index 9dc137d7c..ec0d72001 100644
--- a/web/src/media/addTo/AddToPlaylistDialog.test.tsx
+++ b/web/src/media/addTo/AddToPlaylistDialog.test.tsx
@@ -13,7 +13,10 @@ const groups = [
];
const playlistsByGroup: Record = {
- '1': [{ id: 10, isSystem: false, name: 'Morning', playlistGroupId: 1 }],
+ '1': [
+ { id: 10, isSystem: false, name: 'Morning', playlistGroupId: 1 },
+ { id: 11, isSystem: true, name: 'System Reserved', playlistGroupId: 1 }
+ ],
'2': [{ id: 20, isSystem: false, name: 'Evening', playlistGroupId: 2 }]
};
@@ -82,6 +85,29 @@ describe('AddToPlaylistDialog', () => {
expect(onAdded).toHaveBeenCalledWith('Morning');
});
+ it('filters out system playlists and groups', async () => {
+ vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input.toString();
+ const method = (init?.method ?? 'GET').toUpperCase();
+ if (url === '/api/playlists/groups' && method === 'GET') {
+ return Promise.resolve(
+ jsonResponse([...groups, { id: 9, isSystem: true, name: 'System Group', playlistCount: 1 }])
+ );
+ }
+ const playlistsMatch = /^\/api\/playlists\?playlistGroupId=(\d+)$/.exec(url);
+ if (playlistsMatch && method === 'GET') {
+ return Promise.resolve(jsonResponse(playlistsByGroup[playlistsMatch[1]] ?? []));
+ }
+ return Promise.resolve(new Response(null, { status: 204 }));
+ });
+
+ render( {}} open />);
+
+ await waitFor(() => expect(screen.getByRole('option', { name: 'Morning' })).toBeTruthy());
+ expect(screen.queryByRole('option', { name: 'System Reserved' })).toBeNull();
+ expect(screen.queryByRole('option', { name: 'System Group' })).toBeNull();
+ });
+
it('reloads playlists when the group changes', async () => {
mockApi();
render( {}} open />);
diff --git a/web/src/media/addTo/AddToPlaylistDialog.tsx b/web/src/media/addTo/AddToPlaylistDialog.tsx
index 3c02eca11..fcb0fbc81 100644
--- a/web/src/media/addTo/AddToPlaylistDialog.tsx
+++ b/web/src/media/addTo/AddToPlaylistDialog.tsx
@@ -66,8 +66,11 @@ function AddToPlaylistDialogBody({ onClose, items, onAdded }: AddToPlaylistDialo
return;
}
- setPlaylists(result);
- setSelectedPlaylist(result.length > 0 ? String(result[0].id) : '');
+ // System playlists are ErsatzTV-managed (e.g. per-channel scheduling playlists) and
+ // must not be edited directly — mirror Blazor by hiding them from the picker.
+ const editable = result.filter((playlist) => !playlist.isSystem);
+ setPlaylists(editable);
+ setSelectedPlaylist(editable.length > 0 ? String(editable[0].id) : '');
setPlaylistsLoading(false);
})
.catch((error: unknown) => {
@@ -87,13 +90,14 @@ function AddToPlaylistDialogBody({ onClose, items, onAdded }: AddToPlaylistDialo
return;
}
- setGroups(result);
+ const editable = result.filter((group) => !group.isSystem);
+ setGroups(editable);
setLoadError(null);
setLoading(false);
- if (result.length > 0) {
- setSelectedGroup(String(result[0].id));
- loadPlaylists(result[0].id);
+ if (editable.length > 0) {
+ setSelectedGroup(String(editable[0].id));
+ loadPlaylists(editable[0].id);
}
})
.catch((error: unknown) => {
diff --git a/web/src/media/addTo/AddToScheduleDialog.test.tsx b/web/src/media/addTo/AddToScheduleDialog.test.tsx
index 9cbb6f39b..5d318237c 100644
--- a/web/src/media/addTo/AddToScheduleDialog.test.tsx
+++ b/web/src/media/addTo/AddToScheduleDialog.test.tsx
@@ -123,15 +123,29 @@ describe('AddToScheduleDialog', () => {
expect(onAdded).toHaveBeenCalledWith('Weekends');
});
- it('maps a Movie item to the Movie collection type', async () => {
+ // The server validator (ProgramScheduleItemCommandBase.CollectionTypeMustBeValid) only accepts
+ // shows / seasons / artists as per-media-item schedule items; a movie must hit the defensive
+ // "can't be added" path instead of POSTing a payload the server would 422.
+ it('refuses a non-schedulable kind (movie) without posting', async () => {
const { calls } = mockApi();
render( {}} open />);
+ await waitFor(() => expect(screen.getByText(/can’t be added to a schedule/)).toBeTruthy());
+ const submit = screen.getByRole('button', { name: /Add to schedule/ });
+ expect(submit).toHaveProperty('disabled', true);
+ fireEvent.click(submit);
+ expect(calls.some((call) => call.method === 'POST')).toBe(false);
+ });
+
+ it('maps an Artist item to the Artist collection type', async () => {
+ const { calls } = mockApi();
+ render( {}} open />);
+
await waitFor(() => expect(screen.getByRole('option', { name: 'Weekdays' })).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: /Add to schedule/ }));
await waitFor(() => expect(calls.some((call) => call.url === '/api/schedules/1/items')).toBe(true));
const addCall = calls.find((call) => call.url === '/api/schedules/1/items');
- expect(addCall?.body).toMatchObject({ collectionType: 'Movie', mediaItemId: 7 });
+ expect(addCall?.body).toMatchObject({ collectionType: 'Artist', mediaItemId: 7 });
});
});
diff --git a/web/src/media/addTo/scheduleItem.ts b/web/src/media/addTo/scheduleItem.ts
index 7da02a68d..2ea111596 100644
--- a/web/src/media/addTo/scheduleItem.ts
+++ b/web/src/media/addTo/scheduleItem.ts
@@ -8,21 +8,13 @@ type CollectionType = components['schemas']['CollectionType'];
// "Add all results" flow supplies this so callers never page through every result).
export type AddToItems = LibraryBrowseItem[] | { requestOverride: AddItemsToCollectionRequest };
-// Each addable browse kind maps 1:1 onto the CollectionType member of the same name (the
-// CollectionType enum carries a per-kind member for every media-item subtype). Non-addable kinds
-// (Collection / SmartCollection / MultiCollection / RerunCollection / Playlist) are not schedulable
-// as a single media item, so they have no mapping here. This mirrors how Blazor's per-list pages
-// call AddProgramScheduleItem.ForMediaItem — e.g. the show page passes CollectionType.TelevisionShow,
-// TelevisionSeasonList.razor passes CollectionType.TelevisionSeason.
+// Only shows, seasons and artists are schedulable as a single media item. This matches both
+// Blazor's AddProgramScheduleItem.ForMediaItem call sites (only the show / season / artist list
+// pages offer "Add to schedule") and the server validator
+// ProgramScheduleItemCommandBase.CollectionTypeMustBeValid, which 422s every other
+// per-media-item CollectionType. Every other kind maps to null (not schedulable).
const MEDIA_TYPE_TO_COLLECTION_TYPE: Partial> = {
Artist: 'Artist',
- Episode: 'Episode',
- Image: 'Image',
- Movie: 'Movie',
- MusicVideo: 'MusicVideo',
- OtherVideo: 'OtherVideo',
- RemoteStream: 'RemoteStream',
- Song: 'Song',
TelevisionSeason: 'TelevisionSeason',
TelevisionShow: 'TelevisionShow'
};
diff --git a/web/src/screens/MediaBrowseScreen.test.tsx b/web/src/screens/MediaBrowseScreen.test.tsx
index b0b184610..50812c246 100644
--- a/web/src/screens/MediaBrowseScreen.test.tsx
+++ b/web/src/screens/MediaBrowseScreen.test.tsx
@@ -82,6 +82,54 @@ describe('MediaBrowseScreen', () => {
});
});
+ it('shows a success toast after a bulk add', async () => {
+ mockFetch();
+ render();
+
+ await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', { name: 'Select' }));
+ fireEvent.click(screen.getByText('Blade Runner'));
+ fireEvent.click(screen.getByRole('button', { name: /Add to collection/ }));
+
+ await waitFor(() => expect(screen.getByRole('option', { name: 'Favorites' })).toBeTruthy());
+ fireEvent.click(screen.getAllByRole('button', { name: /Add to collection/ }).at(-1)!);
+
+ await waitFor(() => expect(screen.getByText('Added to “Favorites”')).toBeInTheDocument());
+ // Selection cleared alongside the toast.
+ expect(screen.queryByText(/selected/)).toBeNull();
+ });
+
+ it('hides the per-tile Add-to menu while select mode is on', async () => {
+ mockFetch();
+ render();
+
+ await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
+ expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Select' }));
+ expect(screen.queryByRole('button', { name: 'Add to…' })).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }));
+ expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
+ });
+
+ it('offers a Seasons kind that browses TelevisionSeason items', async () => {
+ const fetchSpy = mockFetch();
+ render();
+
+ await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
+ expect(screen.getByRole('option', { name: 'Seasons' })).toBeTruthy();
+
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'seasons' } });
+
+ await waitFor(() => {
+ const seasonCall = fetchSpy.mock.calls.find(([url]) =>
+ String(url).startsWith('/api/library/browse') && String(url).includes('mediaType=TelevisionSeason')
+ );
+ expect(seasonCall).toBeTruthy();
+ });
+ });
+
it('selects every loaded item with Select all on page', async () => {
mockFetch();
render();
diff --git a/web/src/screens/MediaBrowseScreen.tsx b/web/src/screens/MediaBrowseScreen.tsx
index 5cc4636ae..12247b839 100644
--- a/web/src/screens/MediaBrowseScreen.tsx
+++ b/web/src/screens/MediaBrowseScreen.tsx
@@ -11,7 +11,7 @@ import {
Search,
TriangleAlert
} from 'lucide-react';
-import { Button, Card, IconButton, Input, Select, Spinner } from '../components';
+import { Button, Card, IconButton, Input, Select, Spinner, Toast } from '../components';
import {
getLibraryBrowseItems,
messageFromLibraryBrowseError,
@@ -35,10 +35,12 @@ interface MediaKind {
mediaType: LibraryBrowseMediaType;
}
-// Matches the legacy Blazor media IA (no top-level Seasons page; seasons live under a show).
+// Matches the legacy Blazor media IA, plus a top-level Seasons kind (Blazor only reaches seasons
+// through a show drill-in; the browse endpoint supports them directly, so we expose them).
const KINDS: MediaKind[] = [
{ slug: 'movies', label: 'Movies', mediaType: 'Movie' },
{ slug: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' },
+ { slug: 'seasons', label: 'Seasons', mediaType: 'TelevisionSeason' },
{ slug: 'episodes', label: 'Episodes', mediaType: 'Episode' },
{ slug: 'artists', label: 'Artists', mediaType: 'Artist' },
{ slug: 'music-videos', label: 'Music Videos', mediaType: 'MusicVideo' },
@@ -57,6 +59,8 @@ type BrowseState =
| { items: []; error: string; status: 'error'; totalCount: 0 }
| { items: []; error: null; status: 'loading'; totalCount: 0 };
+type Notice = { tone: 'ok' | 'error'; message: string };
+
export function MediaBrowseScreen() {
const params = new URLSearchParams(window.location.search);
const initialKind = kindFromSlug(params.get('kind'));
@@ -72,6 +76,7 @@ export function MediaBrowseScreen() {
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState