Files
ersatztv/web/src/api/playouts.ts
T
timothy 9b7718fd7c feat(web): playout create/update API client for all schedule kinds (#144 S5 (#162))
Regenerate v1.d.ts from the updated OpenAPI spec and add createPlayout()/
updatePlayoutDetails() to the playouts API client, with URL/body-assert tests
following the ffmpegProfiles.test.ts pattern.
2026-07-07 17:08:49 +02:00

339 lines
10 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel'];
export type PlayoutDetail = components['schemas']['PlayoutResponseModel'];
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[];
items: PlayoutItem[];
playout: PlayoutDetail | null;
playouts: PlayoutSummary[];
selectedPlayoutId: number | null;
totalCount: number;
warningsCount: number;
}
type PlayoutsScreenBase = Omit<PlayoutsScreenData, 'items' | 'playout'>;
export type PlayoutsScreenQueryState =
| {
data: PlayoutsScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActivePlayout: (playoutId: number) => void;
setShowFiller: (showFiller: boolean) => void;
showFiller: boolean;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type PlayoutsScreenState =
| { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getPlayouts(): Promise<PlayoutsPage> {
return request<PlayoutsPage>('/api/playouts');
}
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
}
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
}
export function getPlayoutWarningsCount(): Promise<number> {
return request<number>('/api/playouts/warnings/count');
}
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
return request<PlayoutChannelState[]>('/api/channels/state');
}
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,
error: null,
status: 'loading'
});
const [showFiller, setShowFillerState] = useState(false);
const activeRef = useRef(true);
const selectedPlayoutIdRef = useRef<number | null>(null);
const showFillerRef = useRef(false);
// Snapshot of the last-known base fields (everything but the selected playout's
// detail/items), kept in sync whenever load()/loadChannelStates() succeed. Reading
// this lets setActivePlayout/setShowFiller kick off their fetch without doing the
// fetch from inside a setState updater (impure; StrictMode double-invokes updaters).
const baseRef = useRef<PlayoutsScreenBase>({
channelStates: [],
playouts: [],
selectedPlayoutId: null,
totalCount: 0,
warningsCount: 0
});
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSelectedPlayout = useCallback((playoutId: number, base: PlayoutsScreenBase) => {
baseRef.current = base;
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId, showFillerRef.current)])
.then(([playout, itemsPage]) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
// Merge onto whatever channel state the background poll may have landed
// while this request was in flight, rather than clobbering it with the
// snapshot captured when the request started.
const channelStates = current.status === 'success' ? current.data.channelStates : base.channelStates;
return {
data: {
...base,
channelStates,
items: itemsPage.page ?? [],
playout,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (activeRef.current && selectedPlayoutIdRef.current === playoutId) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, []);
const load = useCallback(() => {
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
.then(([playoutsPage, warningsCount, channelStates]) => {
if (!activeRef.current) {
return;
}
const playouts = playoutsPage.page ?? [];
const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current)
? selectedPlayoutIdRef.current
: playouts[0]?.id ?? null;
selectedPlayoutIdRef.current = selectedPlayoutId;
const base: PlayoutsScreenBase = {
channelStates,
playouts,
selectedPlayoutId,
totalCount: playoutsPage.totalCount,
warningsCount
};
baseRef.current = base;
if (selectedPlayoutId == null) {
setState({
data: {
...base,
items: [],
playout: null
},
error: null,
itemsLoading: false,
status: 'success'
});
return;
}
loadSelectedPlayout(selectedPlayoutId, base);
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, [loadSelectedPlayout]);
const loadChannelStates = useCallback(() => {
getPlayoutChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
baseRef.current = { ...baseRef.current, channelStates };
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
itemsLoading: current.itemsLoading,
status: 'success'
};
});
})
.catch(() => {
// Keep the monitor visible on background polling failures.
});
}, []);
useEffect(() => {
load();
const intervalId = window.setInterval(loadChannelStates, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [load, loadChannelStates, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActivePlayout = useCallback((playoutId: number) => {
selectedPlayoutIdRef.current = playoutId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: [],
playout: null,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: true,
status: 'success'
};
});
const base: PlayoutsScreenBase = { ...baseRef.current, selectedPlayoutId: playoutId };
loadSelectedPlayout(playoutId, base);
}, [loadSelectedPlayout]);
const setShowFiller = useCallback((next: boolean) => {
showFillerRef.current = next;
setShowFillerState(next);
const playoutId = selectedPlayoutIdRef.current;
if (playoutId == null) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return { ...current, itemsLoading: true };
});
getPlayoutItems(playoutId, next)
.then((itemsPage) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items: itemsPage.page ?? [] },
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActivePlayout,
setShowFiller,
showFiller,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}