diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index d4d4a1b56..636b98286 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -71,6 +71,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)] diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index 054fcdc43..ff57c40d0 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -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(), Arg.Any()) .Returns(Option.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(); created.StatusCode.ShouldBe(201); @@ -78,7 +81,7 @@ public class PlayoutControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.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(c => c.ChannelId == 3 && c.ProgramScheduleId == 4), @@ -91,7 +94,9 @@ public class PlayoutControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(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(); var problem = notFound.Value.ShouldBeOfType(); @@ -105,7 +110,9 @@ public class PlayoutControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(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(); var problem = unprocessable.Value.ShouldBeOfType(); @@ -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(); + var problem = unprocessable.Value.ShouldBeOfType(); + problem.Status.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Create_Should_Map_Block_Kind_With_No_Extra_Fields() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreatePlayoutResponse(9))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + await _controller.Create(new CreatePlayoutRequest(3, PlayoutScheduleKind.Block, null, null), CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => c.ChannelId == 3), + Arg.Any()); + } + + [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(); + } + + [Test] + public async Task Create_Should_Map_Sequential_Kind_With_ScheduleFile() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreatePlayoutResponse(9))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + await _controller.Create( + new CreatePlayoutRequest(3, PlayoutScheduleKind.Sequential, null, "/config/schedule.yml"), + CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => c.ChannelId == 3 && c.ScheduleFile == "/config/schedule.yml"), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Update( + 404, + new UpdatePlayoutDetailsRequest(null, null), + CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problem = notFound.Value.ShouldBeOfType(); + problem.Status.ShouldBe(404); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Update_Should_Apply_DailyRebuildTime_And_Return_200() + { + PlayoutNameViewModel vm = MakePlayout(9); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(vm with { DbDailyRebuildTime = TimeSpan.FromHours(4) })); + + IActionResult result = await _controller.Update( + 9, + new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlayoutId == 9 && c.DailyRebuildTime == Some(TimeSpan.FromHours(4))), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Clear_DailyRebuildTime_When_Null() + { + PlayoutNameViewModel vm = MakePlayout(9); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(vm)); + + await _controller.Update(9, new UpdatePlayoutDetailsRequest(null, null), CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => c.PlayoutId == 9 && c.DailyRebuildTime == Option.None), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_422_When_ScheduleFile_Set_For_Classic_Playout() + { + PlayoutNameViewModel vm = MakePlayout(9); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Update( + 9, + new UpdatePlayoutDetailsRequest(null, "/config/schedule.yml"), + CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + var problem = unprocessable.Value.ShouldBeOfType(); + problem.Status.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Update_Should_Dispatch_UpdateSequentialPlayout_For_Sequential_Kind() + { + PlayoutNameViewModel vm = MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Sequential }; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(vm)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(vm with { ScheduleFile = "/config/new.yml" })); + + IActionResult result = await _controller.Update( + 9, + new UpdatePlayoutDetailsRequest(null, "/config/new.yml"), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlayoutId == 9 && c.ScheduleFile == "/config/new.yml"), + Arg.Any()); + } + [Test] public async Task Delete_Should_Return_204_On_Success() { diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 82d3c7889..27b9f8f99 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -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,97 @@ public class PlayoutController(IMediator mediator) : ControllerBase [Required] [FromBody] CreatePlayoutRequest request, CancellationToken cancellationToken) { - Either result = await mediator.Send(request.ToCommand(), cancellationToken); - return await result.Match( + Either commandOrError = request.ToCommand(); + return await commandOrError.Match( Left: error => Task.FromResult(error.ToErrorResult()), - Right: async created => + Right: async command => { - Option 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 result = await mediator.Send(command, cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async created => + { + Option 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 Update( + int id, + [Required] [FromBody] UpdatePlayoutDetailsRequest request, + CancellationToken cancellationToken) + { + Option 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(); + } + } + + Option dailyRebuildTime = request.DailyRebuildTime is { } t ? Some(t) : Option.None; + Either result = + await mediator.Send(new UpdatePlayout(id, dailyRebuildTime), cancellationToken); + + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async playout => + { + if (!hasScheduleFile) + { + return (IActionResult)new OkObjectResult(ToResponse(playout)); + } + + Either scheduleFileResult = + await UpdateScheduleFile(playout, request.ScheduleFile, cancellationToken); + return scheduleFileResult.Match( + Left: error => error.ToErrorResult(), + Right: updated => (IActionResult)new OkObjectResult(ToResponse(updated))); + }); + } + + private async Task> 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")] diff --git a/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs index d82dcf03a..9a0de4129 100644 --- a/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs +++ b/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs @@ -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 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") + }; } diff --git a/ErsatzTV/Controllers/Api/Requests/UpdatePlayoutDetailsRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdatePlayoutDetailsRequest.cs new file mode 100644 index 000000000..2b1cc16a4 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdatePlayoutDetailsRequest.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +/// +/// 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. +/// +public record UpdatePlayoutDetailsRequest(TimeSpan? DailyRebuildTime, string ScheduleFile); diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 8170cd9ba..d4fb533cf 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -3357,7 +3357,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": { @@ -3542,6 +3543,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" @@ -9071,7 +9177,9 @@ "CreatePlayoutRequest": { "required": [ "channelId", - "programScheduleId" + "scheduleKind", + "programScheduleId", + "scheduleFile" ], "type": "object", "properties": { @@ -9079,9 +9187,21 @@ "type": "integer", "format": "int32" }, + "scheduleKind": { + "$ref": "#/components/schemas/PlayoutScheduleKind" + }, "programScheduleId": { - "type": "integer", + "type": [ + "null", + "integer" + ], "format": "int32" + }, + "scheduleFile": { + "type": [ + "null", + "string" + ] } } }, @@ -12503,6 +12623,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",