Merge remote-tracking branch 'origin/main' into feat/144-s1-blocks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

This commit is contained in:
2026-07-07 18:15:29 +02:00
11 changed files with 1037 additions and 31 deletions
+142
View File
@@ -1834,6 +1834,120 @@ describe('ChicoryTV SPA scaffold', () => {
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
});
it('creates a classic playout from the Add Playout dialog and selects it', async () => {
mockDashboardApi({
channels: [channelSummary({ id: 1, name: 'Retro Cartoons', number: '5' })],
createPlayoutResponse: playout({ id: 42 }),
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
schedules: [schedule({ id: 7, name: 'Weekend Lineup' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add Playout' }));
expect(await screen.findByText('Add playout')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Channel'), { target: { value: '1' } });
await screen.findByRole('option', { name: 'Weekend Lineup' });
fireEvent.change(screen.getByLabelText('Classic schedule'), { target: { value: '7' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(requestBodyFor('/api/playouts')).toMatchObject({
channelId: 1,
programScheduleId: 7,
scheduleKind: 'Classic'
});
});
expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.objectContaining({ method: 'POST' }));
await waitFor(() => {
expect(screen.queryByText('Add playout')).not.toBeInTheDocument();
});
});
it('shows the create-playout error inline when the API rejects the request', async () => {
mockDashboardApi({
channels: [channelSummary({ id: 1, name: 'Retro Cartoons', number: '5' })],
mutationFailures: {
'/api/playouts': {
detail: 'Channel already has one playout',
status: 422,
title: 'Validation failed'
}
},
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
schedules: [schedule({ id: 7, name: 'Weekend Lineup' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add Playout' }));
expect(await screen.findByText('Add playout')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Channel'), { target: { value: '1' } });
await screen.findByRole('option', { name: 'Weekend Lineup' });
fireEvent.change(screen.getByLabelText('Classic schedule'), { target: { value: '7' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(await screen.findByText('Channel already has one playout')).toBeInTheDocument();
});
it('edits daily rebuild time from the playout detail panel', async () => {
mockDashboardApi({
playoutDetails: playout({ id: 20, dailyRebuildTime: '04:00:00' }),
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
updatePlayoutDetailsResponse: playout({ id: 20, dailyRebuildTime: '05:00:00' })
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
expect(await screen.findByText('Edit playout details')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Daily reset time'), { target: { value: '05:00:00' } });
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => {
expect(requestBodyFor('/api/playouts/20')).toMatchObject({ dailyRebuildTime: '05:00:00' });
});
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.objectContaining({ method: 'PUT' }));
await waitFor(() => {
expect(screen.queryByText('Edit playout details')).not.toBeInTheDocument();
});
});
it('shows the schedule-file field only for file-backed playout kinds when editing', async () => {
mockDashboardApi({
playoutDetails: playout({ id: 20, scheduleFile: '/config/schedule.yml', scheduleKind: 'Sequential' }),
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Sequential' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
expect(await screen.findByText('Edit playout details')).toBeInTheDocument();
expect(screen.getByLabelText('Sequential schedule')).toBeInTheDocument();
});
it('shows the dashboard loading state while requests are pending', async () => {
vi.spyOn(window, 'fetch').mockImplementation(() => new Promise<Response>(() => {}));
@@ -3336,6 +3450,8 @@ function mockDashboardApi({
mediaSourcesFailuresBeforeSuccess = 0,
multiCollections = [],
mutationFailures = {},
createPlayoutResponse = null,
updatePlayoutDetailsResponse = null,
playoutDetails = null,
playoutItems = [],
playoutItemsFailure = null,
@@ -3395,6 +3511,8 @@ function mockDashboardApi({
mediaSourcesFailuresBeforeSuccess?: number;
multiCollections?: unknown[];
mutationFailures?: Record<string, unknown>;
createPlayoutResponse?: unknown;
updatePlayoutDetailsResponse?: unknown;
playoutDetails?: unknown;
playoutItems?: unknown[];
playoutItemsFailure?: unknown;
@@ -3626,6 +3744,17 @@ function mockDashboardApi({
}
if (path === '/api/playouts') {
const method = init?.method ?? 'GET';
if (method === 'POST') {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(jsonResponse(createPlayoutResponse ?? playout({ id: 99 }), 201));
}
return Promise.resolve(jsonResponse(playouts));
}
@@ -3667,6 +3796,19 @@ function mockDashboardApi({
}
if (path.match(/^\/api\/playouts\/\d+$/)) {
const method = init?.method ?? 'GET';
if (method === 'PUT') {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(jsonResponse(
updatePlayoutDetailsResponse ?? playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })
));
}
return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })));
}
+358 -12
View File
@@ -79,6 +79,7 @@ import {
Card,
Checkbox,
ChannelLogo,
Dialog,
IconButton,
Input,
NavItem,
@@ -97,12 +98,15 @@ import {
bulkDeleteChannels,
bulkMoveChannelsToGroup,
bulkRenumberChannels,
createPlayout,
deleteChannel,
messageFromError,
addScheduleItem,
deleteScheduleItem,
getSchedules,
resetAllPlayouts,
replaceScheduleItems,
updatePlayoutDetails,
usePlayoutsScreenQuery,
useScheduleScreenQuery,
useDashboardHealthQuery,
@@ -116,6 +120,7 @@ import {
type ChannelSummary,
type ChannelGuideChannel,
type ChannelGuideProgramme,
type CreatePlayoutRequest,
type DashboardChannel,
type SchedulePickerData,
type DashboardChannelState,
@@ -124,6 +129,8 @@ import {
type MediaSource,
type MediaSourceLibrary,
type MediaCollection,
type PlayoutDetail,
type PlayoutScheduleKind,
type ProgramSchedule,
type ProgramScheduleItem,
type PlayoutItem,
@@ -2617,12 +2624,258 @@ function PlayoutsEmptyState() {
);
}
const PLAYOUT_KIND_OPTIONS: Array<{ label: string; value: PlayoutScheduleKind }> = [
{ label: 'Classic', value: 'Classic' },
{ label: 'Block', value: 'Block' },
{ label: 'Sequential', value: 'Sequential' },
{ label: 'Scripted', value: 'Scripted' },
{ label: 'External JSON (dizqueTV)', value: 'ExternalJson' }
];
function scheduleFileHelperText(kind: PlayoutScheduleKind): string {
switch (kind) {
case 'Sequential':
return 'The full path to the sequential schedule (YAML) file';
case 'Scripted':
return 'The command line used to run the scripted schedule';
case 'ExternalJson':
return 'The full path to the JSON (dizqueTV) schedule file';
default:
return '';
}
}
function AddPlayoutDialog({
busy,
channelsWithPlayouts,
error,
onCancel,
onSubmit,
open
}: {
busy: boolean;
channelsWithPlayouts: Set<string>;
error: string | null;
onCancel: () => void;
onSubmit: (request: CreatePlayoutRequest) => void;
open: boolean;
}) {
const channelsQuery = useChannelsQuery();
const [kind, setKind] = useState<PlayoutScheduleKind>('Classic');
const [channelId, setChannelId] = useState('');
const [scheduleId, setScheduleId] = useState('');
const [scheduleFile, setScheduleFile] = useState('');
const [schedules, setSchedules] = useState<ProgramSchedule[] | null>(null);
const schedulesLoading = kind === 'Classic' && schedules === null;
useEffect(() => {
if (kind !== 'Classic' || schedules !== null) {
return;
}
let active = true;
getSchedules()
.then((result) => {
if (active) {
setSchedules(result);
}
})
.catch(() => {
if (active) {
setSchedules([]);
}
});
return () => {
active = false;
};
}, [kind, schedules]);
const channels = channelsQuery.status === 'success' ? channelsQuery.channels : [];
const needsScheduleFile = kind === 'Sequential' || kind === 'Scripted' || kind === 'ExternalJson';
const canSubmit =
channelId.length > 0 &&
(kind !== 'Classic' || scheduleId.length > 0) &&
(!needsScheduleFile || scheduleFile.trim().length > 0);
const submit = () => {
if (!canSubmit) {
return;
}
const request: CreatePlayoutRequest = {
channelId: Number(channelId),
programScheduleId: kind === 'Classic' ? Number(scheduleId) : null,
scheduleFile: needsScheduleFile ? scheduleFile.trim() : null,
scheduleKind: kind
};
onSubmit(request);
};
return (
<Dialog
footer={
<>
<Button disabled={busy} onClick={onCancel} variant="secondary">
Cancel
</Button>
<Button disabled={busy || !canSubmit} loading={busy} onClick={submit} variant="primary">
Create
</Button>
</>
}
onClose={onCancel}
open={open}
title="Add playout"
width={480}
>
<Select
label="Kind"
onChange={(event) => setKind(event.target.value as PlayoutScheduleKind)}
options={PLAYOUT_KIND_OPTIONS}
value={kind}
/>
<div style={{ marginTop: 12 }}>
<Select
label="Channel"
onChange={(event) => setChannelId(event.target.value)}
options={[
{ label: 'Select a channel...', value: '' },
...channels.map((channel) => ({
label: `${channel.number} - ${channel.name}${channelsWithPlayouts.has(channel.number) ? ' (already has a playout)' : ''}`,
value: `${channel.id}`
}))
]}
value={channelId}
/>
</div>
{kind === 'Classic' && (
<div style={{ marginTop: 12 }}>
<Select
disabled={schedulesLoading}
label="Classic schedule"
onChange={(event) => setScheduleId(event.target.value)}
options={[
{ label: schedulesLoading ? 'Loading...' : 'Select a schedule...', value: '' },
...(schedules ?? []).map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: `${schedule.id}` }))
]}
value={scheduleId}
/>
</div>
)}
{kind === 'Block' && (
<p style={{ color: 'var(--ctv-text-muted, #888)', marginTop: 12 }}>Block templates are added later.</p>
)}
{needsScheduleFile && (
<div style={{ marginTop: 12 }}>
<Input
label={kind === 'ExternalJson' ? 'JSON (dizqueTV) schedule' : `${kind} schedule`}
onChange={(event) => setScheduleFile(event.target.value)}
value={scheduleFile}
/>
<small style={{ color: 'var(--ctv-text-muted, #888)' }}>{scheduleFileHelperText(kind)}</small>
</div>
)}
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</Dialog>
);
}
function EditPlayoutDetailsDialog({
busy,
error,
onCancel,
onSubmit,
open,
playout
}: {
busy: boolean;
error: string | null;
onCancel: () => void;
onSubmit: (dailyRebuildTime: string | null, scheduleFile: string | null) => void;
open: boolean;
playout: PlayoutDetail;
}) {
const canEditScheduleFile =
playout.scheduleKind === 'Sequential' || playout.scheduleKind === 'Scripted' || playout.scheduleKind === 'ExternalJson';
const [dailyRebuildTime, setDailyRebuildTime] = useState(playout.dailyRebuildTime ?? '');
const [scheduleFile, setScheduleFile] = useState(playout.scheduleFile ?? '');
const rebuildOptions = [
{ label: 'Do not automatically reset', value: '' },
...Array.from({ length: 47 }, (_, index) => {
const totalMinutes = (index + 1) * 30;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const value = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
const label = new Date(2000, 0, 1, hours, minutes).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return { label, value };
})
];
return (
<Dialog
footer={
<>
<Button disabled={busy} onClick={onCancel} variant="secondary">
Cancel
</Button>
<Button
disabled={busy || (canEditScheduleFile && scheduleFile.trim().length === 0)}
loading={busy}
onClick={() => onSubmit(dailyRebuildTime.length > 0 ? dailyRebuildTime : null, canEditScheduleFile ? scheduleFile.trim() : null)}
variant="primary"
>
Save changes
</Button>
</>
}
onClose={onCancel}
open={open}
title="Edit playout details"
width={480}
>
<Select
label="Daily reset time"
onChange={(event) => setDailyRebuildTime(event.target.value)}
options={rebuildOptions}
value={dailyRebuildTime}
/>
{canEditScheduleFile && (
<div style={{ marginTop: 12 }}>
<Input
label={playout.scheduleKind === 'ExternalJson' ? 'JSON (dizqueTV) schedule' : `${playout.scheduleKind} schedule`}
onChange={(event) => setScheduleFile(event.target.value)}
value={scheduleFile}
/>
</div>
)}
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</Dialog>
);
}
function PlayoutsScreen() {
const query = usePlayoutsScreenQuery();
const [filter, setFilter] = useState('');
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const mutatingRef = useRef(false);
const [addOpen, setAddOpen] = useState(false);
const [addBusy, setAddBusy] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editBusy, setEditBusy] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
if (query.status === 'loading') {
return <PlayoutsLoadingState />;
@@ -2635,16 +2888,7 @@ function PlayoutsScreen() {
const { channelStates, items, playout, playouts, selectedPlayoutId, totalCount, warningsCount } = query.data;
const { itemsLoading, setShowFiller, showFiller } = query;
const selectedSummary = playouts.find((candidate) => candidate.id === selectedPlayoutId) ?? playouts[0] ?? null;
if (!selectedSummary) {
return <PlayoutsEmptyState />;
}
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber);
const nowPlaying = selectedState?.nowPlaying ?? null;
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
const nextItem = nextPlayoutItem(items, nowItem);
const filteredPlayouts = filterPlayouts(playouts, filter);
const channelsWithPlayouts = new Set(playouts.map((candidate) => candidate.channelNumber));
const setMutatingState = (value: boolean) => {
mutatingRef.current = value;
@@ -2670,13 +2914,77 @@ function PlayoutsScreen() {
});
};
const submitAddPlayout = (request: CreatePlayoutRequest) => {
setAddBusy(true);
setAddError(null);
createPlayout(request)
.then((created) => {
setAddOpen(false);
query.setActivePlayout(created.id);
query.refresh();
})
.catch((error: unknown) => {
setAddError(messageFromError(error));
})
.finally(() => {
setAddBusy(false);
});
};
const submitEditPlayoutDetails = (dailyRebuildTime: string | null, scheduleFile: string | null) => {
if (!selectedSummary) {
return;
}
setEditBusy(true);
setEditError(null);
updatePlayoutDetails(selectedSummary.id, { dailyRebuildTime, scheduleFile })
.then(() => {
setEditOpen(false);
query.refresh();
})
.catch((error: unknown) => {
setEditError(messageFromError(error));
})
.finally(() => {
setEditBusy(false);
});
};
if (!selectedSummary) {
return (
<div className="ctv-playouts-screen">
<section className="ctv-playouts-header">
<span className="ctv-playouts-header-spacer" />
<Button onClick={() => setAddOpen(true)} startIcon={<Plus aria-hidden="true" size={15} />} variant="primary">Add Playout</Button>
</section>
<PlayoutsEmptyState />
<AddPlayoutDialog
busy={addBusy}
channelsWithPlayouts={channelsWithPlayouts}
error={addError}
key={`add-${addOpen}`}
onCancel={() => setAddOpen(false)}
onSubmit={submitAddPlayout}
open={addOpen}
/>
</div>
);
}
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber);
const nowPlaying = selectedState?.nowPlaying ?? null;
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
const nextItem = nextPlayoutItem(items, nowItem);
const filteredPlayouts = filterPlayouts(playouts, filter);
return (
<div className="ctv-playouts-screen">
<section className="ctv-playouts-header">
<Button disabled={mutating} onClick={resetAll} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Reset all playouts</Button>
<span className="ctv-playouts-header-spacer" />
<Badge tone={warningsCount > 0 ? 'warn' : 'neutral'} dot={warningsCount > 0}>{warningsCount} warning{warningsCount === 1 ? '' : 's'}</Badge>
<Button disabled startIcon={<Plus aria-hidden="true" size={15} />} variant="primary">Add Playout</Button>
<Button onClick={() => setAddOpen(true)} startIcon={<Plus aria-hidden="true" size={15} />} variant="primary">Add Playout</Button>
</section>
{mutationError && (
@@ -2760,7 +3068,24 @@ function PlayoutsScreen() {
</span>
</div>
</Card>
<Card title="Playout">
<Card
title="Playout"
actions={
playout && (
<Button
onClick={() => {
setEditError(null);
setEditOpen(true);
}}
size="sm"
startIcon={<Pencil aria-hidden="true" size={13} />}
variant="ghost"
>
Edit details
</Button>
)
}
>
<div className="ctv-playout-detail-grid">
<Input disabled label="Mode" value={playout?.playoutMode ?? 'Unknown'} />
<Input disabled label="Schedule" value={playout?.scheduleName ?? selectedSummary.scheduleName} />
@@ -2814,6 +3139,27 @@ function PlayoutsScreen() {
</Card>
</section>
</div>
<AddPlayoutDialog
busy={addBusy}
channelsWithPlayouts={channelsWithPlayouts}
error={addError}
key={`add-${addOpen}`}
onCancel={() => setAddOpen(false)}
onSubmit={submitAddPlayout}
open={addOpen}
/>
{playout && (
<EditPlayoutDetailsDialog
busy={editBusy}
error={editError}
key={`edit-${editOpen}-${playout.id}`}
onCancel={() => setEditOpen(false)}
onSubmit={submitEditPlayoutDetails}
open={editOpen}
playout={playout}
/>
)}
</div>
);
}
+7 -1
View File
@@ -395,7 +395,9 @@ export interface components {
};
"CreatePlayoutRequest": {
"channelId": number;
"programScheduleId": number;
"scheduleKind": components["schemas"]["PlayoutScheduleKind"];
"programScheduleId": null | number;
"scheduleFile": null | string;
};
"CreateResolutionRequest": {
"width": number;
@@ -1054,6 +1056,10 @@ export interface components {
"searchingMinimumLogLevel": components["schemas"]["LogEventLevel"];
"streamingMinimumLogLevel": components["schemas"]["LogEventLevel"];
"httpMinimumLogLevel": components["schemas"]["LogEventLevel"];
};
"UpdatePlayoutDetailsRequest": {
"dailyRebuildTime": null | string;
"scheduleFile": null | string;
};
"UpdatePlayoutSettingsRequest": {
"daysToBuild": number;
+78
View File
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlayout, updatePlayoutDetails, type CreatePlayoutRequest } from './playouts';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
describe('playouts api client', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('createPlayout POSTs a classic playout request', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 7 }, 201));
const body: CreatePlayoutRequest = {
channelId: 1,
programScheduleId: 2,
scheduleFile: null,
scheduleKind: 'Classic'
};
await expect(createPlayout(body)).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toMatchObject({
channelId: 1,
programScheduleId: 2,
scheduleKind: 'Classic'
});
});
it('createPlayout POSTs a sequential playout request with a schedule file', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 9 }, 201));
const body: CreatePlayoutRequest = {
channelId: 3,
programScheduleId: null,
scheduleFile: '/config/schedule.yml',
scheduleKind: 'Sequential'
};
await createPlayout(body);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toMatchObject({
channelId: 3,
scheduleFile: '/config/schedule.yml',
scheduleKind: 'Sequential'
});
});
it('updatePlayoutDetails PUTs to the id route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3 }));
await updatePlayoutDetails(3, { dailyRebuildTime: '04:00:00', scheduleFile: null });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/3');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toMatchObject({ dailyRebuildTime: '04:00:00' });
});
it('updatePlayoutDetails sends null dailyRebuildTime to clear the daily reset', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3 }));
await updatePlayoutDetails(3, { dailyRebuildTime: null, scheduleFile: null });
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toMatchObject({ dailyRebuildTime: null });
});
});
+17
View File
@@ -8,6 +8,9 @@ export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
export type PlayoutScheduleKind = components['schemas']['PlayoutScheduleKind'];
export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest'];
export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest'];
export interface PlayoutsScreenData {
channelStates: PlayoutChannelState[];
@@ -64,6 +67,20 @@ export function resetAllPlayouts(): Promise<void> {
return request<void>('/api/playouts/reset-all', { method: 'POST' });
}
export function createPlayout(body: CreatePlayoutRequest): Promise<PlayoutDetail> {
return request<PlayoutDetail>('/api/playouts', {
body,
method: 'POST'
});
}
export function updatePlayoutDetails(playoutId: number, body: UpdatePlayoutDetailsRequest): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`, {
body,
method: 'PUT'
});
}
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
const [state, setState] = useState<PlayoutsScreenState>({
data: null,