Merge slice D: media browse/detail mutation surface (#209)

This commit is contained in:
2026-07-10 00:14:37 +02:00
4 changed files with 473 additions and 13 deletions
@@ -0,0 +1,99 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MediaBrowseScreen } from './MediaBrowseScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const items = [
{ artwork: '', collectionType: 'Movie', id: 1, mediaType: 'Movie', title: 'Blade Runner' },
{ artwork: '', collectionType: 'Movie', id: 2, mediaType: 'Movie', title: 'Alien' }
];
function mockFetch() {
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/library/browse')) {
return Promise.resolve(jsonResponse({ page: items, totalCount: items.length }));
}
if (url === '/api/collections') {
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
}
if (url === '/api/playlists/groups') {
return Promise.resolve(jsonResponse([]));
}
if (url === '/api/schedules') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
}
describe('MediaBrowseScreen', () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState(null, '', '/app/media?kind=movies');
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
});
it('renders a per-tile Add-to menu on every card', async () => {
mockFetch();
render(<MediaBrowseScreen />);
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
});
it('supports select mode and adds the selected items to a collection', async () => {
const fetchSpy = mockFetch();
render(<MediaBrowseScreen />);
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
// No selection bar until we enter select mode and pick something.
expect(screen.queryByText(/selected/)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
fireEvent.click(screen.getByText('Blade Runner'));
expect(screen.getByText('1 selected')).toBeInTheDocument();
fireEvent.click(screen.getByText('Alien'));
expect(screen.getByText('2 selected')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Add to collection/ }));
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
// The first existing collection (Favorites, id 7) is preselected — just submit.
await waitFor(() => expect(screen.getByRole('option', { name: 'Favorites' })).toBeTruthy());
fireEvent.click(screen.getAllByRole('button', { name: /Add to collection/ }).at(-1)!);
await waitFor(() => {
const addCall = fetchSpy.mock.calls.find(
([url, init]) => String(url) === '/api/collections/7/items' && (init as RequestInit | undefined)?.method === 'POST'
);
expect(addCall).toBeTruthy();
const body = JSON.parse(String((addCall![1] as RequestInit).body));
expect(body.movieIds).toEqual([1, 2]);
});
});
it('selects every loaded item with Select all on page', async () => {
mockFetch();
render(<MediaBrowseScreen />);
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: 'Select all on page' }));
expect(screen.getByText('2 selected')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }));
expect(screen.queryByText(/selected/)).toBeNull();
});
});
+115 -3
View File
@@ -1,5 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, FolderTree, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
import {
ChevronLeft,
ChevronRight,
FolderPlus,
FolderTree,
Info,
ListChecks,
ListVideo,
RefreshCw,
Search,
TriangleAlert
} from 'lucide-react';
import { Button, Card, IconButton, Input, Select, Spinner } from '../components';
import {
getLibraryBrowseItems,
@@ -7,10 +18,15 @@ import {
type LibraryBrowseItem,
type LibraryBrowseMediaType
} from '../api';
import { AddToCollectionDialog, AddToMenu, AddToPlaylistDialog } from '../media/addTo';
import { MediaPosterCard } from '../media/MediaPosterCard';
import { mediaDetailPath } from '../media/mediaKinds';
import { navigateToPath } from '../routing';
function itemKey(item: LibraryBrowseItem): string {
return `${item.mediaType}-${item.id}`;
}
const PAGE_SIZE = 100;
interface MediaKind {
@@ -51,6 +67,11 @@ export function MediaBrowseScreen() {
const [query, setQuery] = useState(initialQuery);
const [pageNum, setPageNum] = useState(0);
const [state, setState] = useState<BrowseState>({ items: [], error: null, status: 'loading', totalCount: 0 });
// Screen-level multi-select. Deliberately opt-in (a "Select" toggle) rather than Blazor's
// always-on selection, so the default browse experience stays a plain drill-in.
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(new Map());
const [bulkDialog, setBulkDialog] = useState<'collection' | 'playlist' | null>(null);
const activeRef = useRef(true);
const seqRef = useRef(0);
@@ -66,6 +87,7 @@ export function MediaBrowseScreen() {
const handle = window.setTimeout(() => {
setPageNum(0);
setQuery(queryInput.trim());
setSelected(new Map());
}, 300);
return () => window.clearTimeout(handle);
@@ -96,6 +118,7 @@ export function MediaBrowseScreen() {
setPageNum(0);
setQueryInput('');
setQuery('');
setSelected(new Map());
// Keep the URL shareable/back-navigable without remounting the whole screen.
window.history.replaceState(null, '', `/app/media?kind=${next.slug}`);
};
@@ -105,6 +128,42 @@ export function MediaBrowseScreen() {
load();
};
const goToPage = (updater: (current: number) => number) => {
setPageNum(updater);
setSelected(new Map());
};
const toggleSelectMode = () => {
setSelectMode((on) => !on);
setSelected(new Map());
};
const toggleSelect = (item: LibraryBrowseItem) => {
setSelected((current) => {
const next = new Map(current);
const key = itemKey(item);
if (next.has(key)) {
next.delete(key);
} else {
next.set(key, item);
}
return next;
});
};
const selectAllOnPage = () => {
setSelected((current) => {
const next = new Map(current);
for (const item of state.items) {
next.set(itemKey(item), item);
}
return next;
});
};
const clearSelection = () => setSelected(new Map());
const selectedItems = Array.from(selected.values());
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / PAGE_SIZE)) : 1;
return (
@@ -134,6 +193,14 @@ export function MediaBrowseScreen() {
Folder Browser
</Button>
)}
<Button
onClick={toggleSelectMode}
size="sm"
startIcon={<ListChecks aria-hidden="true" size={14} />}
variant={selectMode ? 'primary' : 'secondary'}
>
{selectMode ? 'Done' : 'Select'}
</Button>
<Button
onClick={refresh}
size="sm"
@@ -144,6 +211,48 @@ export function MediaBrowseScreen() {
</Button>
</div>
{selected.size > 0 && (
<div className="ctv-channels-actionbar">
<span className="ctv-channels-selected">{selected.size} selected</span>
<Button onClick={selectAllOnPage} size="sm" variant="secondary">
Select all on page
</Button>
<span className="ctv-channels-spacer" />
<Button
onClick={() => setBulkDialog('collection')}
size="sm"
startIcon={<FolderPlus aria-hidden="true" size={14} />}
variant="secondary"
>
Add to collection
</Button>
<Button
onClick={() => setBulkDialog('playlist')}
size="sm"
startIcon={<ListVideo aria-hidden="true" size={14} />}
variant="secondary"
>
Add to playlist
</Button>
<Button onClick={clearSelection} size="sm" variant="ghost">
Clear selection
</Button>
</div>
)}
<AddToCollectionDialog
items={selectedItems}
onAdded={clearSelection}
onClose={() => setBulkDialog(null)}
open={bulkDialog === 'collection'}
/>
<AddToPlaylistDialog
items={selectedItems}
onAdded={clearSelection}
onClose={() => setBulkDialog(null)}
open={bulkDialog === 'playlist'}
/>
{state.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
@@ -173,9 +282,12 @@ export function MediaBrowseScreen() {
const detailPath = mediaDetailPath(item);
return (
<MediaPosterCard
actions={<AddToMenu compact items={[item]} />}
item={item}
key={`${item.mediaType}-${item.id}`}
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
onToggleSelect={selectMode ? toggleSelect : undefined}
selected={selected.has(itemKey(item))}
/>
);
})}
@@ -187,7 +299,7 @@ export function MediaBrowseScreen() {
<span className="ctv-channels-spacer" />
<IconButton
disabled={pageNum === 0}
onClick={() => setPageNum((current) => Math.max(0, current - 1))}
onClick={() => goToPage((current) => Math.max(0, current - 1))}
size="sm"
title="Previous page"
>
@@ -198,7 +310,7 @@ export function MediaBrowseScreen() {
</span>
<IconButton
disabled={pageNum + 1 >= totalPages}
onClick={() => setPageNum((current) => current + 1)}
onClick={() => goToPage((current) => current + 1)}
size="sm"
title="Next page"
>
+130 -2
View File
@@ -1,6 +1,11 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MovieDetailScreen, ShowDetailScreen } from './MediaDetailScreen';
import {
ArtistDetailScreen,
MovieDetailScreen,
SeasonDetailScreen,
ShowDetailScreen
} from './MediaDetailScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
@@ -26,6 +31,67 @@ const movie = {
fanArt: ''
};
function showDetail(mediaSourceKind: string) {
return {
id: 42,
libraryId: 3,
mediaSourceKind,
title: 'The Show',
year: '2001',
plot: 'Show plot',
genres: [],
tags: [],
studios: [],
networks: [],
contentRatings: [],
languages: [],
actors: [],
poster: '',
fanArt: ''
};
}
const season = {
id: 8,
showId: 42,
title: 'The Show',
name: 'Season 1',
year: '2001',
poster: '',
fanArt: ''
};
const artist = {
id: 11,
name: 'The Artist',
disambiguation: '',
biography: 'Bio',
genres: [],
styles: [],
moods: [],
languages: [],
thumbnail: '',
fanArt: ''
};
// Serves a detail record on its /api/<kind>/<id> route, an empty child browse page, and stubs for
// the Add-to dialog loads so opening the plus menu never errors.
function mockDetailFetch(detail: unknown) {
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/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
return Promise.resolve(jsonResponse([]));
}
if (url.startsWith('/api/libraries/') && url.includes('/scan-show')) {
return Promise.resolve(new Response(null, { status: 200 }));
}
return Promise.resolve(jsonResponse(detail));
});
}
describe('media detail screens', () => {
afterEach(() => {
cleanup();
@@ -50,4 +116,66 @@ describe('media detail screens', () => {
await waitFor(() => expect(screen.getByText('Show not found.')).toBeInTheDocument());
});
it('offers an Add-to menu on the movie detail page', async () => {
mockDetailFetch(movie);
render(<MovieDetailScreen id={5} />);
await waitFor(() => expect(screen.getByText('The Movie')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Add to…' })).toBeInTheDocument();
});
it('offers an Add-to menu on the season and artist detail pages', async () => {
mockDetailFetch(season);
const { unmount } = render(<SeasonDetailScreen id={8} />);
await waitFor(() => expect(screen.getByText('The Show — Season 1')).toBeInTheDocument());
expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThanOrEqual(1);
unmount();
mockDetailFetch(artist);
render(<ArtistDetailScreen id={11} />);
await waitFor(() => expect(screen.getByText('The Artist')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Add to…' })).toBeInTheDocument();
});
it('hides per-show scan for Local libraries and shows it for Plex', async () => {
mockDetailFetch(showDetail('Local'));
const { unmount } = render(<ShowDetailScreen id={42} />);
await waitFor(() => expect(screen.getByText('The Show')).toBeInTheDocument());
expect(screen.queryByRole('button', { name: 'Quick scan' })).toBeNull();
unmount();
const fetchSpy = mockDetailFetch(showDetail('Plex'));
render(<ShowDetailScreen id={42} />);
await waitFor(() => expect(screen.getByText('The Show')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Quick scan' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Deep scan' }));
await waitFor(() => {
const scanCall = fetchSpy.mock.calls.find(([url]) => String(url) === '/api/libraries/3/scan-show');
expect(scanCall).toBeTruthy();
const body = JSON.parse(String((scanCall![1] as RequestInit).body));
expect(body).toMatchObject({ deepScan: true, showTitle: 'The Show' });
});
await waitFor(() => expect(screen.getByText('Scan queued')).toBeInTheDocument());
});
it('exposes Media Info and Troubleshoot actions on episode cards', async () => {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/library/browse')) {
return Promise.resolve(
jsonResponse({ page: [{ artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Pilot' }], totalCount: 1 })
);
}
if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(jsonResponse(season));
});
render(<SeasonDetailScreen id={8} />);
await waitFor(() => expect(screen.getByText('Pilot')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Media Info' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Troubleshoot Playback' })).toBeInTheDocument();
});
});
+129 -8
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, ChevronLeft, ChevronRight, Info, Stethoscope, TriangleAlert } from 'lucide-react';
import { ArrowLeft, ChevronLeft, ChevronRight, Info, ScanSearch, Stethoscope, TriangleAlert } from 'lucide-react';
import { Button, Card, Dialog, IconButton, Spinner, Tag } from '../components';
import {
ApiError,
@@ -11,6 +11,7 @@ import {
getShow,
messageFromLibraryBrowseError,
messageFromMediaDetailError,
scanShow,
type ArtistDetail,
type LibraryBrowseItem,
type LibraryBrowseMediaType,
@@ -19,12 +20,27 @@ import {
type SeasonDetail,
type ShowDetail
} from '../api';
import { AddToMenu } from '../media/addTo';
import { MediaPosterCard } from '../media/MediaPosterCard';
import { mediaDetailPath, parseDurationSeconds } from '../media/mediaKinds';
import { navigateToPath } from '../routing';
const CHILD_PAGE_SIZE = 60;
// Media source kinds whose libraries support the single-show rescan endpoint.
const REMOTE_SOURCE_KINDS = new Set(['Plex', 'Jellyfin', 'Emby']);
// Builds a minimal LibraryBrowseItem for a detail page's own subject so it can feed the shared
// Add-to menu/dialogs, which only read id/mediaType/title off each item (see toAddItemsRequest /
// AddToScheduleDialog). The remaining fields are inert here.
function detailBrowseItem(mediaType: LibraryBrowseMediaType, id: number, title: string): LibraryBrowseItem {
return { artwork: '', id, mediaType, title } as unknown as LibraryBrowseItem;
}
function troubleshootPath(mediaItemId: number): string {
return `/app/troubleshooting/playback?mediaItem=${mediaItemId}`;
}
type DetailState<T> =
| { status: 'loading' }
| { status: 'notfound' }
@@ -182,11 +198,13 @@ function StateWarning({ state, path }: { state: string | null | undefined; path?
function ChildGrid({
heading,
mediaType,
parentId
parentId,
renderActions
}: {
heading: string;
mediaType: LibraryBrowseMediaType;
parentId: number;
renderActions?: (item: LibraryBrowseItem) => ReactNode;
}) {
const [items, setItems] = useState<LibraryBrowseItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
@@ -262,6 +280,7 @@ function ChildGrid({
const detailPath = mediaDetailPath(item);
return (
<MediaPosterCard
actions={renderActions?.(item)}
item={item}
key={`${item.mediaType}-${item.id}`}
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
@@ -514,14 +533,14 @@ export function MovieDetailScreen({ id }: { id: number }) {
Media Info
</Button>
<Button
onClick={() => navigateToPath(`/app/troubleshooting/playback?mediaItem=${id}`)}
onClick={() => navigateToPath(troubleshootPath(id))}
size="sm"
startIcon={<Stethoscope size={14} />}
variant="secondary"
>
Troubleshoot Playback
</Button>
{/* Add-to-collection / add-to-playlist mutations are out of scope here; tracked by #153 / #155. */}
<AddToMenu items={[detailBrowseItem('Movie', id, movie.title)]} />
</>
}
chips={
@@ -550,6 +569,67 @@ export function MovieDetailScreen({ id }: { id: number }) {
);
}
function ShowScanControls({ show }: { show: ShowDetail }) {
const [scanning, setScanning] = useState<false | 'quick' | 'deep'>(false);
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const runScan = (deepScan: boolean) => {
setScanning(deepScan ? 'deep' : 'quick');
setMessage(null);
scanShow(show.libraryId, { deepScan, showTitle: show.title })
.then(() => {
if (activeRef.current) {
setScanning(false);
setMessage({ kind: 'ok', text: 'Scan queued' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setScanning(false);
setMessage({ kind: 'error', text: messageFromMediaDetailError(error, 'Unable to queue scan') });
}
});
};
return (
<>
<Button
disabled={scanning !== false}
loading={scanning === 'quick'}
onClick={() => runScan(false)}
size="sm"
startIcon={<ScanSearch size={14} />}
variant="secondary"
>
Quick scan
</Button>
<Button
disabled={scanning !== false}
loading={scanning === 'deep'}
onClick={() => runScan(true)}
size="sm"
startIcon={<ScanSearch size={14} />}
variant="secondary"
>
Deep scan
</Button>
{message ? (
<span className={message.kind === 'error' ? 'ctv-field-error' : 'ctv-channels-selected'} role={message.kind === 'error' ? 'alert' : 'status'}>
{message.text}
</span>
) : null}
</>
);
}
export function ShowDetailScreen({ id }: { id: number }) {
const state = useDetail<ShowDetail>(useCallback(() => getShow(id), [id]));
@@ -564,6 +644,7 @@ export function ShowDetailScreen({ id }: { id: number }) {
}
const show = state.data;
const canScanShow = REMOTE_SOURCE_KINDS.has(show.mediaSourceKind);
return (
<>
@@ -571,6 +652,12 @@ export function ShowDetailScreen({ id }: { id: number }) {
Back
</Button>
<DetailShell
actions={
<>
<AddToMenu items={[detailBrowseItem('TelevisionShow', id, show.title)]} />
{canScanShow ? <ShowScanControls show={show} /> : null}
</>
}
chips={
<div className="ctv-detail-chipstack">
<ChipRow label="Genres" values={show.genres} />
@@ -588,7 +675,12 @@ export function ShowDetailScreen({ id }: { id: number }) {
title={show.title}
>
<ActorsRow actors={show.actors} />
<ChildGrid heading="Seasons" mediaType="TelevisionSeason" parentId={id} />
<ChildGrid
heading="Seasons"
mediaType="TelevisionSeason"
parentId={id}
renderActions={(item) => <AddToMenu compact items={[item]} />}
/>
</DetailShell>
</>
);
@@ -596,6 +688,7 @@ export function ShowDetailScreen({ id }: { id: number }) {
export function SeasonDetailScreen({ id }: { id: number }) {
const state = useDetail<SeasonDetail>(useCallback(() => getSeason(id), [id]));
const [infoItemId, setInfoItemId] = useState<number | null>(null);
if (state.status === 'loading') {
return <Loading />;
@@ -608,6 +701,7 @@ export function SeasonDetailScreen({ id }: { id: number }) {
}
const season = state.data;
const seasonTitle = `${season.title}${season.name}`;
return (
<>
@@ -620,13 +714,34 @@ export function SeasonDetailScreen({ id }: { id: number }) {
Back to show
</Button>
<DetailShell
actions={<AddToMenu items={[detailBrowseItem('TelevisionSeason', id, seasonTitle)]} />}
fanart={season.fanArt}
poster={season.poster}
subtitle={season.year}
title={`${season.title}${season.name}`}
title={seasonTitle}
>
<ChildGrid heading="Episodes" mediaType="Episode" parentId={id} />
<ChildGrid
heading="Episodes"
mediaType="Episode"
parentId={id}
renderActions={(item) => (
<>
<AddToMenu compact items={[item]} />
<IconButton onClick={() => setInfoItemId(item.id)} size="sm" title="Media Info">
<Info aria-hidden="true" size={14} />
</IconButton>
<IconButton onClick={() => navigateToPath(troubleshootPath(item.id))} size="sm" title="Troubleshoot Playback">
<Stethoscope aria-hidden="true" size={14} />
</IconButton>
</>
)}
/>
</DetailShell>
<MediaInfoDialog
mediaItemId={infoItemId ?? 0}
onClose={() => setInfoItemId(null)}
open={infoItemId !== null}
/>
</>
);
}
@@ -652,6 +767,7 @@ export function ArtistDetailScreen({ id }: { id: number }) {
Back
</Button>
<DetailShell
actions={<AddToMenu items={[detailBrowseItem('Artist', id, artist.name)]} />}
chips={
<div className="ctv-detail-chipstack">
<ChipRow label="Genres" values={artist.genres} />
@@ -666,7 +782,12 @@ export function ArtistDetailScreen({ id }: { id: number }) {
subtitle={artist.disambiguation}
title={artist.name}
>
<ChildGrid heading="Music Videos" mediaType="MusicVideo" parentId={id} />
<ChildGrid
heading="Music Videos"
mediaType="MusicVideo"
parentId={id}
renderActions={(item) => <AddToMenu compact items={[item]} />}
/>
</DetailShell>
</>
);