diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index 26769b640..db7d23a6a 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -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)] 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..eeb29ff4c 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,100 @@ 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(); + } + } + + // 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 scheduleFileResult = + await UpdateScheduleFile(playout, request.ScheduleFile, cancellationToken); + foreach (BaseError error in scheduleFileResult.LeftToSeq()) + { + return error.ToErrorResult(); + } + } + } + + Option dailyRebuildTime = request.DailyRebuildTime is { } t ? Some(t) : Option.None; + Either 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> 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 4dc0918e4..eb41db993 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -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", diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 810e62080..7801f14a7 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(() => {})); @@ -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; + 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)) }))); } diff --git a/web/src/App.tsx b/web/src/App.tsx index 14f2218b4..ba05b7892 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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; + error: string | null; + onCancel: () => void; + onSubmit: (request: CreatePlayoutRequest) => void; + open: boolean; +}) { + const channelsQuery = useChannelsQuery(); + const [kind, setKind] = useState('Classic'); + const [channelId, setChannelId] = useState(''); + const [scheduleId, setScheduleId] = useState(''); + const [scheduleFile, setScheduleFile] = useState(''); + const [schedules, setSchedules] = useState(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 ( + + + + + } + onClose={onCancel} + open={open} + title="Add playout" + width={480} + > + 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} + /> + + {kind === 'Classic' && ( +
+ setScheduleFile(event.target.value)} + value={scheduleFile} + /> + {scheduleFileHelperText(kind)} +
+ )} + {error && ( + + {error} + + )} +
+ ); +} + +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 ( + + + + + } + onClose={onCancel} + open={open} + title="Edit playout details" + width={480} + > + setScheduleFile(event.target.value)} + value={scheduleFile} + /> + + )} + {error && ( + + {error} + + )} + + ); +} + function PlayoutsScreen() { const query = usePlayoutsScreenQuery(); const [filter, setFilter] = useState(''); const [mutationError, setMutationError] = useState(null); const [mutating, setMutating] = useState(false); const mutatingRef = useRef(false); + const [addOpen, setAddOpen] = useState(false); + const [addBusy, setAddBusy] = useState(false); + const [addError, setAddError] = useState(null); + const [editOpen, setEditOpen] = useState(false); + const [editBusy, setEditBusy] = useState(false); + const [editError, setEditError] = useState(null); if (query.status === 'loading') { return ; @@ -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 ; - } - - 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 ( +
+
+ + +
+ + setAddOpen(false)} + onSubmit={submitAddPlayout} + open={addOpen} + /> +
+ ); + } + + 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 (
0 ? 'warn' : 'neutral'} dot={warningsCount > 0}>{warningsCount} warning{warningsCount === 1 ? '' : 's'} - +
{mutationError && ( @@ -2760,7 +3068,24 @@ function PlayoutsScreen() {
- + { + setEditError(null); + setEditOpen(true); + }} + size="sm" + startIcon={