merge main into feat/145-block-history (post-#181); regenerate OpenAPI artifacts
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:25:49 +02:00
co-authored by Claude Fable 5
13 changed files with 632 additions and 42 deletions
+58
View File
@@ -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<typeof vi.spyOn>): 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);
});
});
+6
View File
@@ -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<PagedLibraryBrowseItems>(`/api/library/browse${queryString ? `?${queryString}` : ''}`);
+64
View File
@@ -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<typeof mockBuilderApi>): 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(<ChannelBuilderScreen />);
// 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);
});
});
+137 -6
View File
@@ -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({
)}
</div>
</div>
{onSeasons && (
<IconButton
size="sm"
title="Browse seasons"
onClick={(event) => {
event.stopPropagation();
onSeasons();
}}
>
<FolderTree size={15} aria-hidden="true" />
</IconButton>
)}
<IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={onAdd}>
{added ? (
<Check size={15} color="var(--action-primary)" aria-hidden="true" />
@@ -399,11 +414,111 @@ function LibCard({
<Check size={14} aria-hidden="true" />
</div>
)}
{onSeasons && (
<button
type="button"
className="ctv-builder-seasons-btn ctv-press"
title="Browse seasons"
onClick={(event) => {
event.stopPropagation();
onSeasons();
}}
>
<FolderTree size={13} aria-hidden="true" />
Seasons
</button>
)}
</div>
</div>
);
}
// ---- Seasons drill-in ------------------------------------------------------
// Expands a TelevisionShow tile into its seasons so a specific season can be
// added to the lineup without flooding the main grid with per-season tiles
// (issue #180). Mounted fresh per show (keyed on show id) so initial state is
// 'loading' and the fetch effect only ever calls setState in its callbacks.
function SeasonsDialog({
show,
addedKeys,
onAdd,
onClose
}: {
show: LibraryBrowseItem;
addedKeys: Set<string>;
onAdd: (item: LibraryBrowseItem) => void;
onClose: () => void;
}) {
const [seasons, setSeasons] = useState<LibraryBrowseItem[]>([]);
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: show.id, pageSize: 100 })
.then((result) => {
if (active) {
setSeasons(result.page ?? []);
setStatus('success');
}
})
.catch((loadError: unknown) => {
if (active) {
setError(messageFromError(loadError));
setStatus('error');
}
});
return () => {
active = false;
};
}, [show.id]);
return (
<Dialog
open
onClose={onClose}
title={`Seasons — ${show.title}`}
width={520}
footer={
<Button variant="secondary" onClick={onClose}>
Done
</Button>
}
>
{status === 'loading' ? (
<div className="ctv-builder-empty">
<Spinner size={18} tone="accent" />
</div>
) : status === 'error' ? (
<div className="ctv-builder-empty" role="alert">
{error}
</div>
) : seasons.length === 0 ? (
<div className="ctv-builder-empty">This show has no seasons.</div>
) : (
<div className="ctv-builder-seasons-list">
{seasons.map((season) => {
const added = addedKeys.has(lineupKey(season));
return (
<div key={lineupKey(season)} className="ctv-builder-seasons-row">
<Poster item={season} width={40} height={54} mini />
<div className="ctv-builder-seasons-row-title">{season.title}</div>
<IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={() => onAdd(season)}>
{added ? (
<Check size={15} color="var(--action-primary)" aria-hidden="true" />
) : (
<Plus size={15} aria-hidden="true" />
)}
</IconButton>
</div>
);
})}
</div>
)}
</Dialog>
);
}
// ---- Library browse data hook ---------------------------------------------
interface BrowseState {
status: 'loading' | 'error' | 'success';
@@ -422,7 +537,7 @@ async function loadCollections(query: string): Promise<{ page: LibraryBrowseItem
return { page: merged, totalCount: merged.length };
}
// Fan out across the four pickable library kinds (movies/shows/seasons/artists)
// Fan out across the pickable top-level library kinds (movies/shows/artists)
// instead of the unscoped browse, which now also returns episodes/songs/etc.
// Mirrors loadCollections's per-kind Promise.all pattern, but preserves real
// paging (each kind keeps its own pageNum/totalCount, summed across kinds) so
@@ -674,6 +789,9 @@ function ChannelBuilder({
const [compact, setCompact] = useState(false);
const [libraryId, setLibraryId] = useState<number | null>(null);
// Show whose seasons are being browsed in the drill-in dialog (issue #180).
const [seasonsShow, setSeasonsShow] = useState<LibraryBrowseItem | null>(null);
// debounce search input -> query
useEffect(() => {
const timer = window.setTimeout(() => setQuery(searchInput.trim()), 280);
@@ -1144,6 +1262,9 @@ function ChannelBuilder({
compact={compact}
added={addedKeys.has(lineupKey(item))}
onAdd={() => addItem(item)}
onSeasons={
item.mediaType === 'TelevisionShow' ? () => setSeasonsShow(item) : undefined
}
onDragStart={() => setDragLib(lineupKey(item))}
onDragEnd={() => {
if (dragLib != null) {
@@ -1571,6 +1692,16 @@ function ChannelBuilder({
</aside>
</div>
{seasonsShow && (
<SeasonsDialog
key={seasonsShow.id}
show={seasonsShow}
addedKeys={addedKeys}
onAdd={addItem}
onClose={() => setSeasonsShow(null)}
/>
)}
<ConfirmDialog
open={confirmClear}
title="Clear the lineup?"
+46 -16
View File
@@ -266,15 +266,15 @@ describe('CollectionsScreen', () => {
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
});
it('fans the add-items search out over the 4 addable kinds and merges the results', async () => {
const byType: Record<string, { id: number; mediaType: string; title: string }> = {
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
};
const addItemsByType: Record<string, { id: number; mediaType: string; title: string }> = {
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
};
const fetchMock = mockApi({
function mockAddItemsApi() {
return mockApi({
onRequest: (url) => {
if (url.startsWith('/api/library/browse')) {
const params = new URL(url, 'http://localhost').searchParams;
@@ -287,14 +287,16 @@ describe('CollectionsScreen', () => {
}
const mediaType = params.get('mediaType');
const item = mediaType ? byType[mediaType] : undefined;
const item = mediaType ? addItemsByType[mediaType] : undefined;
return jsonResponse(item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 });
}
return null;
}
});
}
async function openAddItemsDialog() {
render(<CollectionsScreen />);
await screen.findByText('Favorites');
@@ -302,8 +304,21 @@ describe('CollectionsScreen', () => {
await screen.findByText(/best-effort search preview/);
fireEvent.click(screen.getByRole('button', { name: 'Add items' }));
return screen.getByRole('dialog');
}
const dialog = screen.getByRole('dialog');
function addItemsBrowseTypes(fetchMock: ReturnType<typeof mockApi>): 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 !== '');
}
it('default add-items search excludes seasons and fans out over movies/shows/artists', async () => {
const fetchMock = mockAddItemsApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'a' }
});
@@ -311,15 +326,30 @@ describe('CollectionsScreen', () => {
expect(await within(dialog).findByText('Adventure Time')).toBeInTheDocument();
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
expect(within(dialog).getByText('Melon Season 1')).toBeInTheDocument();
expect(within(dialog).getByText('Between Movie and Show')).toBeInTheDocument();
// Seasons are excluded from the default fan-out (issue #180).
expect(within(dialog).queryByText('Melon Season 1')).not.toBeInTheDocument();
const browseTypes = fetchMock.mock.calls
expect(new Set(addItemsBrowseTypes(fetchMock))).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
});
it('selecting the Seasons filter searches TelevisionSeason and surfaces seasons', async () => {
const fetchMock = mockAddItemsApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'a' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Seasons' }));
expect(await within(dialog).findByText('Melon Season 1')).toBeInTheDocument();
// The explicit filter narrows the fan-out to seasons only.
expect(within(dialog).queryByText('Zathura')).not.toBeInTheDocument();
const seasonCall = 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);
expect(new Set(browseTypes)).toEqual(new Set(['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']));
.some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'TelevisionSeason');
expect(seasonCall).toBe(true);
});
});
+41 -4
View File
@@ -46,9 +46,25 @@ import { TYPE_LABEL } from '../media/mediaKinds';
type Tab = 'manual' | 'smart';
// Every kind that can be added to a manual collection from the picker.
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist'];
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
// The default fan-out excludes seasons so a multi-season show doesn't flood the
// results with per-season rows (issue #180); seasons stay reachable via the
// explicit media-kind filter below.
const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist'];
type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType'];
const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [
{ label: 'All', value: 'all' },
{ label: 'Movies', value: 'Movie' },
{ label: 'Shows', value: 'TelevisionShow' },
{ label: 'Seasons', value: 'TelevisionSeason' },
{ label: 'Artists', value: 'Artist' }
];
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
@@ -301,15 +317,17 @@ function AddItemsDialog({
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [kindFilter, setKindFilter] = useState<MediaKindFilter>('all');
const runSearch = async () => {
const runSearch = async (filter: MediaKindFilter = kindFilter) => {
setSearching(true);
setError(null);
try {
const trimmed = query.trim();
const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter];
const perKind = await Promise.all(
ADDABLE_TYPE_LIST.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
kinds.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
);
const merged = perKind
.flatMap((result) => result.page ?? [])
@@ -323,6 +341,11 @@ function AddItemsDialog({
}
};
const selectKindFilter = (filter: MediaKindFilter) => {
setKindFilter(filter);
void runSearch(filter);
};
const toggle = (item: LibraryBrowseItem) => {
const key = `${item.mediaType}:${item.id}`;
setSelected((current) => {
@@ -397,9 +420,23 @@ function AddItemsDialog({
Search
</Button>
</form>
<div className="ctv-collections-picker-filters" role="group" aria-label="Filter by media kind">
{MEDIA_KIND_FILTERS.map((filter) => (
<button
aria-pressed={kindFilter === filter.value}
className={`ctv-collections-picker-filter ctv-press${kindFilter === filter.value ? ' ctv-collections-picker-filter-active' : ''}`}
key={filter.value}
onClick={() => selectKindFilter(filter.value)}
type="button"
>
{filter.label}
</button>
))}
</div>
<p className="ctv-collections-picker-note">
The library search returns movies, shows, seasons and artists. Episodes, music, images and other item kinds
can't be added from here yet.
Add movies, shows, seasons and artists. &ldquo;All&rdquo; searches movies, shows and artists; pick
&ldquo;Seasons&rdquo; to find a specific season. Episodes, music, images and other item kinds can&rsquo;t be
added from here yet.
</p>
{error && (
<span className="ctv-field-error" role="alert">
+80
View File
@@ -2432,6 +2432,57 @@
animation: ctv-pop 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* Seasons drill-in affordance on show tiles (issue #180) */
.ctv-builder-seasons-btn {
position: absolute;
left: 6px;
bottom: 6px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-hairline);
background: color-mix(in srgb, var(--surface-card) 82%, transparent);
color: var(--text-primary);
font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans);
cursor: pointer;
backdrop-filter: blur(2px);
}
.ctv-builder-seasons-btn:hover {
border-color: color-mix(in srgb, var(--action-primary) 45%, transparent);
color: var(--action-primary);
}
.ctv-builder-seasons-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 420px;
overflow-y: auto;
}
.ctv-builder-seasons-row {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--surface-card);
border: 1px solid var(--border-hairline);
}
.ctv-builder-seasons-row-title {
flex: 1;
min-width: 0;
font: var(--weight-medium) var(--text-xs) / 1.15 var(--font-sans);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes ctv-pop {
from {
transform: scale(0.2);
@@ -3703,6 +3754,35 @@
color: var(--text-disabled);
}
/* Media-kind filter chips in the add-items dialog (issue #180) */
.ctv-collections-picker-filters {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ctv-collections-picker-filter {
padding: 4px 12px;
border-radius: var(--radius-pill, 999px);
border: 1px solid var(--border-hairline);
background: var(--surface-card);
color: var(--text-secondary);
font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans);
cursor: pointer;
}
.ctv-collections-picker-filter:hover {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--action-primary) 35%, transparent);
}
.ctv-collections-picker-filter-active {
background: color-mix(in srgb, var(--action-primary) 14%, transparent);
border-color: color-mix(in srgb, var(--action-primary) 45%, transparent);
color: var(--action-primary);
}
.ctv-collections-picker-results {
margin-top: 10px;
display: flex;