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
@@ -82,6 +82,8 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)]
@@ -40,6 +40,7 @@ public class PlayoutControllerTests
ShouldHaveActionRoute(nameof(PlayoutController.GetItems), "GET", "/api/playouts/{id:int}/items");
ShouldHaveActionRoute(nameof(PlayoutController.GetWarningsCount), "GET", "/api/playouts/warnings/count");
ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/playouts");
ShouldHaveActionRoute(nameof(PlayoutController.Update), "PUT", "/api/playouts/{id:int}");
ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/playouts/reset-all");
ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}");
}
@@ -62,7 +63,9 @@ public class PlayoutControllerTests
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(vm));
IActionResult result = await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None);
IActionResult result = await _controller.Create(
new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, 4, null),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
@@ -78,7 +81,7 @@ public class PlayoutControllerTests
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None);
await _controller.Create(new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, 4, null), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateClassicPlayout>(c => c.ChannelId == 3 && c.ProgramScheduleId == 4),
@@ -91,7 +94,9 @@ public class PlayoutControllerTests
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreatePlayoutResponse>(new NotFoundError("missing")));
IActionResult result = await _controller.Create(new CreatePlayoutRequest(404, 4), CancellationToken.None);
IActionResult result = await _controller.Create(
new CreatePlayoutRequest(404, PlayoutScheduleKind.Classic, 4, null),
CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
@@ -105,7 +110,9 @@ public class PlayoutControllerTests
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreatePlayoutResponse>(BaseError.New("bad")));
IActionResult result = await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None);
IActionResult result = await _controller.Create(
new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, 4, null),
CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
@@ -113,6 +120,156 @@ public class PlayoutControllerTests
problem.Title.ShouldBe("Validation failed");
}
[Test]
public async Task Create_Should_Return_422_When_Classic_Missing_ProgramScheduleId()
{
IActionResult result = await _controller.Create(
new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, null, null),
CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
await _mediator.DidNotReceive().Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Map_Block_Kind_With_No_Extra_Fields()
{
_mediator.Send(Arg.Any<CreateBlockPlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreatePlayoutResponse>(new CreatePlayoutResponse(9)));
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
await _controller.Create(new CreatePlayoutRequest(3, PlayoutScheduleKind.Block, null, null), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateBlockPlayout>(c => c.ChannelId == 3),
Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
[TestCase(PlayoutScheduleKind.ExternalJson)]
public async Task Create_Should_Return_422_For_File_Backed_Kinds_Missing_ScheduleFile(PlayoutScheduleKind kind)
{
IActionResult result = await _controller.Create(
new CreatePlayoutRequest(3, kind, null, null),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Create_Should_Map_Sequential_Kind_With_ScheduleFile()
{
_mediator.Send(Arg.Any<CreateSequentialPlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreatePlayoutResponse>(new CreatePlayoutResponse(9)));
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
await _controller.Create(
new CreatePlayoutRequest(3, PlayoutScheduleKind.Sequential, null, "/config/schedule.yml"),
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateSequentialPlayout>(c => c.ChannelId == 3 && c.ScheduleFile == "/config/schedule.yml"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.Update(
404,
new UpdatePlayoutDetailsRequest(null, null),
CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(404);
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Apply_DailyRebuildTime_And_Return_200()
{
PlayoutNameViewModel vm = MakePlayout(9);
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(vm));
_mediator.Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, PlayoutNameViewModel>(vm with { DbDailyRebuildTime = TimeSpan.FromHours(4) }));
IActionResult result = await _controller.Update(
9,
new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdatePlayout>(c => c.PlayoutId == 9 && c.DailyRebuildTime == Some(TimeSpan.FromHours(4))),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Clear_DailyRebuildTime_When_Null()
{
PlayoutNameViewModel vm = MakePlayout(9);
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(vm));
_mediator.Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, PlayoutNameViewModel>(vm));
await _controller.Update(9, new UpdatePlayoutDetailsRequest(null, null), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<UpdatePlayout>(c => c.PlayoutId == 9 && c.DailyRebuildTime == Option<TimeSpan>.None),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_422_When_ScheduleFile_Set_For_Classic_Playout()
{
PlayoutNameViewModel vm = MakePlayout(9);
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(vm));
IActionResult result = await _controller.Update(
9,
new UpdatePlayoutDetailsRequest(null, "/config/schedule.yml"),
CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Dispatch_UpdateSequentialPlayout_For_Sequential_Kind()
{
PlayoutNameViewModel vm = MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Sequential };
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(vm));
_mediator.Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, PlayoutNameViewModel>(vm));
_mediator.Send(Arg.Any<UpdateSequentialPlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, PlayoutNameViewModel>(vm with { ScheduleFile = "/config/new.yml" }));
IActionResult result = await _controller.Update(
9,
new UpdatePlayoutDetailsRequest(null, "/config/new.yml"),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateSequentialPlayout>(c => c.PlayoutId == 9 && c.ScheduleFile == "/config/new.yml"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
+94 -9
View File
@@ -83,7 +83,11 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[HttpPost("/api/playouts")]
[Tags("Playouts")]
[EndpointSummary("Create a classic playout")]
[EndpointSummary("Create a playout")]
[EndpointDescription(
"Creates a playout of any kind (Classic, Block, Sequential, Scripted, or ExternalJson) for a channel. " +
"Classic requires ProgramScheduleId; Sequential/Scripted/ExternalJson require ScheduleFile; Block requires " +
"neither. A channel may only have one playout.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
@@ -92,19 +96,100 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[Required] [FromBody] CreatePlayoutRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreatePlayoutResponse> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Either<BaseError, CreatePlayout> commandOrError = request.ToCommand();
return await commandOrError.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
Right: async command =>
{
Option<PlayoutNameViewModel> playout =
await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken);
return playout.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/playouts/{vm.PlayoutId}", ToResponse(vm)),
None: () => ApiResults.NotFoundProblem());
Either<BaseError, CreatePlayoutResponse> result = await mediator.Send(command, cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<PlayoutNameViewModel> playout =
await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken);
return playout.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/playouts/{vm.PlayoutId}", ToResponse(vm)),
None: () => ApiResults.NotFoundProblem());
});
});
}
[HttpPut("/api/playouts/{id:int}")]
[Tags("Playouts")]
[EndpointSummary("Update playout scheduling details")]
[EndpointDescription(
"DailyRebuildTime is always applied; omit it (null) to clear the daily reset. ScheduleFile is only valid " +
"for Sequential, Scripted, and ExternalJson playouts; omit it (null) to leave it unchanged.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdatePlayoutDetailsRequest request,
CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
var hasScheduleFile = !string.IsNullOrWhiteSpace(request.ScheduleFile);
foreach (PlayoutNameViewModel playout in maybePlayout)
{
if (hasScheduleFile && playout.ScheduleKind is not (PlayoutScheduleKind.Sequential
or PlayoutScheduleKind.Scripted or PlayoutScheduleKind.ExternalJson))
{
BaseError error =
BaseError.New("[ScheduleFile] is only valid for Sequential, Scripted, or ExternalJson playouts");
return error.ToErrorResult();
}
}
// the schedule-file update is the only step that can fail after the pre-checks,
// so it goes first — a rejected file must not leave DailyRebuildTime applied
if (hasScheduleFile)
{
foreach (PlayoutNameViewModel playout in maybePlayout)
{
Either<BaseError, PlayoutNameViewModel> scheduleFileResult =
await UpdateScheduleFile(playout, request.ScheduleFile, cancellationToken);
foreach (BaseError error in scheduleFileResult.LeftToSeq())
{
return error.ToErrorResult();
}
}
}
Option<TimeSpan> dailyRebuildTime = request.DailyRebuildTime is { } t ? Some(t) : Option<TimeSpan>.None;
Either<BaseError, PlayoutNameViewModel> result =
await mediator.Send(new UpdatePlayout(id, dailyRebuildTime), cancellationToken);
return result.Match(
Left: error => error.ToErrorResult(),
Right: playout => (IActionResult)new OkObjectResult(ToResponse(playout)));
}
private async Task<Either<BaseError, PlayoutNameViewModel>> UpdateScheduleFile(
PlayoutNameViewModel playout,
string scheduleFile,
CancellationToken cancellationToken) =>
playout.ScheduleKind switch
{
PlayoutScheduleKind.Sequential => await mediator.Send(
new UpdateSequentialPlayout(playout.PlayoutId, scheduleFile),
cancellationToken),
PlayoutScheduleKind.Scripted => await mediator.Send(
new UpdateScriptedPlayout(playout.PlayoutId, scheduleFile),
cancellationToken),
PlayoutScheduleKind.ExternalJson => await mediator.Send(
new UpdateExternalJsonPlayout(playout.PlayoutId, scheduleFile),
cancellationToken),
_ => BaseError.New("[ScheduleFile] is only valid for Sequential, Scripted, or ExternalJson playouts")
};
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
[Tags("Playouts")]
[EndpointSummary("Reset all playouts")]
@@ -1,8 +1,31 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreatePlayoutRequest(int ChannelId, int ProgramScheduleId)
public record CreatePlayoutRequest(
int ChannelId,
PlayoutScheduleKind ScheduleKind,
int? ProgramScheduleId,
string ScheduleFile)
{
public CreateClassicPlayout ToCommand() => new(ChannelId, ProgramScheduleId);
public Either<BaseError, CreatePlayout> ToCommand() =>
ScheduleKind switch
{
PlayoutScheduleKind.Classic => ProgramScheduleId is { } programScheduleId
? new CreateClassicPlayout(ChannelId, programScheduleId)
: BaseError.New("[ProgramScheduleId] is required for Classic playouts"),
PlayoutScheduleKind.Block => new CreateBlockPlayout(ChannelId),
PlayoutScheduleKind.Sequential => !string.IsNullOrWhiteSpace(ScheduleFile)
? new CreateSequentialPlayout(ChannelId, ScheduleFile)
: BaseError.New("[ScheduleFile] is required for Sequential playouts"),
PlayoutScheduleKind.Scripted => !string.IsNullOrWhiteSpace(ScheduleFile)
? new CreateScriptedPlayout(ChannelId, ScheduleFile)
: BaseError.New("[ScheduleFile] is required for Scripted playouts"),
PlayoutScheduleKind.ExternalJson => !string.IsNullOrWhiteSpace(ScheduleFile)
? new CreateExternalJsonPlayout(ChannelId, ScheduleFile)
: BaseError.New("[ScheduleFile] is required for ExternalJson playouts"),
_ => BaseError.New("[ScheduleKind] must be one of Classic, Block, Sequential, Scripted, ExternalJson")
};
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// DailyRebuildTime is always applied: a null value clears the daily reset (matches the Blazor
/// "Do not automatically reset" option in SchedulePlayoutReset.razor). ScheduleFile is only valid
/// for Sequential, Scripted, and ExternalJson playouts; omit it (null) to leave it unchanged.
/// </summary>
public record UpdatePlayoutDetailsRequest(TimeSpan? DailyRebuildTime, string ScheduleFile);
+145 -3
View File
@@ -4043,7 +4043,8 @@
"tags": [
"Playouts"
],
"summary": "Create a classic playout",
"summary": "Create a playout",
"description": "Creates a playout of any kind (Classic, Block, Sequential, Scripted, or ExternalJson) for a channel. Classic requires ProgramScheduleId; Sequential/Scripted/ExternalJson require ScheduleFile; Block requires neither. A channel may only have one playout.",
"requestBody": {
"content": {
"application/json-patch+json": {
@@ -4228,6 +4229,111 @@
}
}
},
"put": {
"tags": [
"Playouts"
],
"summary": "Update playout scheduling details",
"description": "DailyRebuildTime is always applied; omit it (null) to clear the daily reset. ScheduleFile is only valid for Sequential, Scripted, and ExternalJson playouts; omit it (null) to leave it unchanged.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/UpdatePlayoutDetailsRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePlayoutDetailsRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePlayoutDetailsRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/UpdatePlayoutDetailsRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/PlayoutResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlayoutResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PlayoutResponseModel"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
},
"delete": {
"tags": [
"Playouts"
@@ -10325,7 +10431,9 @@
"CreatePlayoutRequest": {
"required": [
"channelId",
"programScheduleId"
"scheduleKind",
"programScheduleId",
"scheduleFile"
],
"type": "object",
"properties": {
@@ -10333,9 +10441,21 @@
"type": "integer",
"format": "int32"
},
"scheduleKind": {
"$ref": "#/components/schemas/PlayoutScheduleKind"
},
"programScheduleId": {
"type": "integer",
"type": [
"null",
"integer"
],
"format": "int32"
},
"scheduleFile": {
"type": [
"null",
"string"
]
}
}
},
@@ -13806,6 +13926,28 @@
}
}
},
"UpdatePlayoutDetailsRequest": {
"required": [
"dailyRebuildTime",
"scheduleFile"
],
"type": "object",
"properties": {
"dailyRebuildTime": {
"pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$",
"type": [
"null",
"string"
]
},
"scheduleFile": {
"type": [
"null",
"string"
]
}
}
},
"UpdatePlayoutSettingsRequest": {
"required": [
"daysToBuild",
+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,