feat(api): #288/#197 wrap ChannelViewModel + re-key playout/reset to {id}

GetById/Create/Update return ChannelResponseModel via new GetChannelByIdForApi
read-side query; POST /api/channels/{id:int}/playout/reset (new
GetPlayoutIdByChannelId; by-number kept for HlsSessionWorker broadcast).

Refs #288 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 02:08:12 +02:00
co-authored by Claude Opus 4.8
parent 9e2d160884
commit 5f89bfc1a0
13 changed files with 273 additions and 53 deletions
+3 -1
View File
@@ -998,6 +998,7 @@ describe('ChicoryTV SPA scaffold', () => {
confirm: true,
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
channelStates: [{ channelId: 7, channelNumber: '5.1', onAir: true, nowPlaying: null }],
playouts: { page: [listPlayout({ id: 20, channelNumber: '5.1' })], totalCount: 1 }
});
@@ -1009,8 +1010,9 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
await waitFor(() => {
// Keyed on the resolved channel id (7), not the playout id (20) or the channel number.
expect(window.fetch).toHaveBeenCalledWith(
'/api/channels/5.1/playout/reset',
'/api/channels/7/playout/reset',
expect.objectContaining({ method: 'POST' })
);
});
+10 -1
View File
@@ -1829,8 +1829,17 @@ function PlayoutsScreen() {
if (!selectedSummary) {
return;
}
// The reset endpoint keys on the immutable channel id. The playout summary only carries the
// channel number, so resolve the id from channel state (which covers every channel).
const channelId = channelStates.find(
(state) => state.channelNumber === selectedSummary.channelNumber
)?.channelId;
if (channelId == null) {
setMutationError('Unable to resolve the channel for this playout.');
return;
}
runMutation(`Reset the playout for ${selectedSummary.channelName}?`, () =>
resetChannelPlayout(selectedSummary.channelNumber)
resetChannelPlayout(channelId)
);
};
+3 -12
View File
@@ -115,25 +115,16 @@ describe('playouts api client', () => {
expect(init).toMatchObject({ method: 'DELETE' });
});
it('resetChannelPlayout POSTs to the channel playout reset route without a mode', async () => {
it('resetChannelPlayout POSTs to the id-keyed channel playout reset route without a mode', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('12.3');
await resetChannelPlayout(20);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/12.3/playout/reset');
expect(url).toBe('/api/channels/20/playout/reset');
expect(init).toMatchObject({ method: 'POST' });
});
it('resetChannelPlayout url-encodes the channel number', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('a b');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/a%20b/playout/reset');
});
it('erasePlayoutItems POSTs to the erase-items route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
+3 -2
View File
@@ -123,8 +123,9 @@ export function deletePlayout(playoutId: number): Promise<void> {
// Resets the given channel's playout. The server picks the correct default mode per schedule kind
// (Classic → Refresh, others → Reset), matching the Blazor "Reset Playout" action — so no mode is sent.
export function resetChannelPlayout(channelNumber: string): Promise<void> {
return request<void>(`/api/channels/${encodeURIComponent(channelNumber)}/playout/reset`, { method: 'POST' });
// Keyed on the immutable channel id (not the user-mutable number), matching the /api/channels/{id} contract.
export function resetChannelPlayout(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}/playout/reset`, { method: 'POST' });
}
export function erasePlayoutItems(playoutId: number): Promise<void> {