diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs index 1ea848ea2..81eea96fd 100644 --- a/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/UpdateExternalJsonPlayoutHandler.cs @@ -54,7 +54,9 @@ public class playout.ProgramSchedule?.Name ?? string.Empty, playout.ScheduleFile, playout.DailyRebuildTime, - playout.BuildStatus); + playout.BuildStatus, + playout.DecoId, + playout.Deco?.Name); } private static Task> Validate( diff --git a/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs index c01238705..1ca83a979 100644 --- a/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/UpdatePlayoutHandler.cs @@ -46,7 +46,9 @@ public class UpdatePlayoutHandler : IRequestHandler> Validate( diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs index 805a48214..329fc33b3 100644 --- a/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs @@ -49,7 +49,9 @@ public class playout.ProgramSchedule?.Name ?? string.Empty, playout.ScheduleFile, playout.DailyRebuildTime, - playout.BuildStatus); + playout.BuildStatus, + playout.DecoId, + playout.Deco?.Name); } private async Task> Validate( diff --git a/ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs index 802134c32..1f50282ab 100644 --- a/ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/UpdateSequentialPlayoutHandler.cs @@ -54,7 +54,9 @@ public class playout.ProgramSchedule?.Name ?? string.Empty, playout.ScheduleFile, playout.DailyRebuildTime, - playout.BuildStatus); + playout.BuildStatus, + playout.DecoId, + playout.Deco?.Name); } private static Task> Validate( diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs index 679ef7900..18d10475e 100644 --- a/ErsatzTV.Application/Playouts/Mapper.cs +++ b/ErsatzTV.Application/Playouts/Mapper.cs @@ -15,7 +15,11 @@ internal static class Mapper playout.ProgramScheduleId == null ? string.Empty : playout.ProgramSchedule.Name, playout.ScheduleFile, playout.DailyRebuildTime, - playout.BuildStatus); + playout.BuildStatus, + playout.DecoId, + // the paged-playouts query does not eager-load Deco (the list response does not surface + // the default deco); GetPlayoutById includes it for the detail response + playout.Deco?.Name); internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) => new( diff --git a/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs index 6abde61f1..6ee27fbf0 100644 --- a/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs +++ b/ErsatzTV.Application/Playouts/PlayoutNameViewModel.cs @@ -11,7 +11,9 @@ public record PlayoutNameViewModel( string ScheduleName, string ScheduleFile, TimeSpan? DbDailyRebuildTime, - PlayoutBuildStatus BuildStatus) + PlayoutBuildStatus BuildStatus, + int? DecoId, + string DecoName) { public Option DailyRebuildTime => Optional(DbDailyRebuildTime); diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs index b765ff7dd..8583f91ac 100644 --- a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs @@ -17,6 +17,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory dbContextFactory .Include(p => p.ProgramSchedule) .Include(p => p.Channel) .Include(p => p.BuildStatus) + .Include(p => p.Deco) .SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken) .MapT(p => new PlayoutNameViewModel( p.Id, @@ -27,6 +28,8 @@ public class GetPlayoutByIdHandler(IDbContextFactory dbContextFactory p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name, p.ScheduleFile, p.DailyRebuildTime, - p.BuildStatus)); + p.BuildStatus, + p.DecoId, + p.DecoId == null ? null : p.Deco.Name)); } } diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutAlternateScheduleResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutAlternateScheduleResponseModel.cs new file mode 100644 index 000000000..1ca1ca38e --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutAlternateScheduleResponseModel.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutAlternateScheduleResponseModel( + int Id, + int Index, + int ProgramScheduleId, + ICollection DaysOfWeek, + ICollection DaysOfMonth, + ICollection MonthsOfYear, + bool LimitToDateRange, + int StartMonth, + int StartDay, + int? StartYear, + int EndMonth, + int EndDay, + int? EndYear); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs index cd382a278..63b08b96b 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs @@ -12,7 +12,9 @@ public record PlayoutResponseModel( string ScheduleName, string? ScheduleFile, TimeSpan? DailyRebuildTime, - PlayoutBuildStatusResponseModel? BuildStatus) + PlayoutBuildStatusResponseModel? BuildStatus, + int? DecoId, + string? DecoName) { public static PlayoutResponseModel From( int id, @@ -23,7 +25,9 @@ public record PlayoutResponseModel( string scheduleName, string? scheduleFile, TimeSpan? dailyRebuildTime, - PlayoutBuildStatusResponseModel? buildStatus) => + PlayoutBuildStatusResponseModel? buildStatus, + int? decoId, + string? decoName) => new( id, scheduleKind, @@ -33,5 +37,7 @@ public record PlayoutResponseModel( scheduleName, scheduleFile, dailyRebuildTime, - buildStatus); + buildStatus, + decoId, + decoName); } diff --git a/ErsatzTV.Core/Api/Scheduling/PlayoutTemplateResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/PlayoutTemplateResponseModel.cs new file mode 100644 index 000000000..1319f17e2 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/PlayoutTemplateResponseModel.cs @@ -0,0 +1,22 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record PlayoutTemplateResponseModel( + int Id, + int Index, + int TemplateId, + string TemplateName, + string TemplateGroupName, + int? DecoTemplateId, + string? DecoTemplateName, + string? DecoTemplateGroupName, + ICollection DaysOfWeek, + ICollection DaysOfMonth, + ICollection MonthsOfYear, + bool LimitToDateRange, + int StartMonth, + int StartDay, + int? StartYear, + int EndMonth, + int EndDay, + int? EndYear); diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index d0d94433d..7ab30a490 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -121,6 +121,14 @@ public class ApiErrorResponseMetadataTests [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(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)] [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)] [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)] diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 3c23a0b4d..9377d2f7c 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -398,6 +398,8 @@ public class ChannelControllerTests "Schedule", string.Empty, null, + null, + null, null); private static ChannelViewModel MakeVm(int id) => diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index ff57c40d0..8cb9dd7f6 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -1,12 +1,16 @@ using System.Reflection; using ErsatzTV.Application.Playouts; +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Application.Scheduling; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; +using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Scheduling; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -43,6 +47,10 @@ public class PlayoutControllerTests 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}"); + ShouldHaveActionRoute(nameof(PlayoutController.GetAlternateSchedules), "GET", "/api/playouts/{id:int}/alternate-schedules"); + ShouldHaveActionRoute(nameof(PlayoutController.ReplaceAlternateSchedules), "PUT", "/api/playouts/{id:int}/alternate-schedules"); + ShouldHaveActionRoute(nameof(PlayoutController.GetTemplates), "GET", "/api/playouts/{id:int}/templates"); + ShouldHaveActionRoute(nameof(PlayoutController.ReplaceTemplates), "PUT", "/api/playouts/{id:int}/templates"); } [Test] @@ -448,6 +456,437 @@ public class PlayoutControllerTests await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); } + // ----- Alternate schedules ----- + + [Test] + public async Task GetAlternateSchedules_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetAlternateSchedules(404, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetAlternateSchedules_Should_Return_422_For_NonClassic_Playout() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + + IActionResult result = await _controller.GetAlternateSchedules(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetAlternateSchedules_Should_Project_Ordered_List() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeAltVm(2, 1, 8), MakeAltVm(1, 0, 7)]); + + IActionResult actionResult = await _controller.GetAlternateSchedules(9, CancellationToken.None); + + var list = actionResult.ShouldBeOfType().Value + .ShouldBeOfType>(); + list.Count.ShouldBe(2); + list[0].Index.ShouldBe(0); + list[0].ProgramScheduleId.ShouldBe(7); + list[1].Index.ShouldBe(1); + list[1].ProgramScheduleId.ShouldBe(8); + } + + [Test] + public async Task ReplaceAlternateSchedules_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.ReplaceAlternateSchedules( + 404, + new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive() + .Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceAlternateSchedules_Should_Return_422_For_NonClassic_Playout() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + + IActionResult result = await _controller.ReplaceAlternateSchedules( + 9, + new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive() + .Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceAlternateSchedules_Should_Return_422_When_Items_Empty() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + IActionResult result = await _controller.ReplaceAlternateSchedules( + 9, + new ReplacePlayoutAlternateSchedulesRequest([]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive() + .Send(Arg.Any(), Arg.Any()); + } + + [TestCase(0, 1, 12, 31)] + [TestCase(13, 1, 12, 31)] + [TestCase(1, 1, 0, 31)] + [TestCase(1, 1, 13, 31)] + public async Task ReplaceAlternateSchedules_Should_Return_422_For_OutOfRange_Month_Without_Dispatch( + int startMonth, + int startDay, + int endMonth, + int endDay) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + var request = new ReplacePlayoutAlternateSchedulesRequest( + [new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]); + + IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + var problem = unprocessable.Value.ShouldBeOfType(); + problem.Status.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await _mediator.DidNotReceive() + .Send(Arg.Any(), Arg.Any()); + } + + [TestCase(0, 1, 12, 31)] + [TestCase(1, 32, 12, 31)] + public async Task ReplaceAlternateSchedules_Should_Return_422_For_OutOfRange_Day_Without_Dispatch( + int startMonth, + int startDay, + int endMonth, + int endDay) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + var request = new ReplacePlayoutAlternateSchedulesRequest( + [new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]); + + IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive() + .Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceAlternateSchedules_Should_Ignore_DateRange_Fields_When_LimitToDateRange_Is_False() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeScheduleVm(7)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeAltVm(1, 0, 7)]); + + // LimitToDateRange is false, so the out-of-range month/day here must not block the save. + var request = new ReplacePlayoutAlternateSchedulesRequest( + [new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], false, 0, 0, null, 13, 32, null)]); + + IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1) + .Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceAlternateSchedules_Should_Return_422_When_ProgramScheduleId_Unknown() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeScheduleVm(7)]); + + IActionResult result = await _controller.ReplaceAlternateSchedules( + 9, + new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7), MakeAltRequest(999)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive() + .Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceAlternateSchedules_Should_Assign_Index_From_Order_And_Return_200() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeScheduleVm(7), MakeScheduleVm(8)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeAltVm(1, 0, 7), MakeAltVm(2, 1, 8)]); + + IActionResult result = await _controller.ReplaceAlternateSchedules( + 9, + new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7), MakeAltRequest(8)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => + c.PlayoutId == 9 && + c.Items.Count == 2 && + c.Items[0].Index == 0 && c.Items[0].ProgramScheduleId == 7 && + c.Items[1].Index == 1 && c.Items[1].ProgramScheduleId == 8), + Arg.Any()); + } + + // ----- Playout templates ----- + + [Test] + public async Task GetTemplates_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetTemplates(404, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetTemplates_Should_Return_422_For_NonBlock_Playout() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + IActionResult result = await _controller.GetTemplates(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetTemplates_Should_Project_Ordered_List() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeTemplateVm(2, 1, 8, 5), MakeTemplateVm(1, 0, 7, null)]); + + IActionResult actionResult = await _controller.GetTemplates(9, CancellationToken.None); + + var list = actionResult.ShouldBeOfType().Value + .ShouldBeOfType>(); + list.Count.ShouldBe(2); + list[0].Index.ShouldBe(0); + list[0].TemplateId.ShouldBe(7); + list[0].DecoTemplateId.ShouldBeNull(); + list[1].Index.ShouldBe(1); + list[1].TemplateId.ShouldBe(8); + list[1].DecoTemplateId.ShouldBe(5); + } + + [Test] + public async Task ReplaceTemplates_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.ReplaceTemplates( + 404, + new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceTemplates_Should_Return_422_For_NonBlock_Playout() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + IActionResult result = await _controller.ReplaceTemplates( + 9, + new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [TestCase(0, 1, 12, 31)] + [TestCase(13, 1, 12, 31)] + [TestCase(1, 1, 0, 31)] + [TestCase(1, 1, 13, 31)] + [TestCase(1, 0, 12, 31)] + [TestCase(1, 32, 12, 31)] + public async Task ReplaceTemplates_Should_Return_422_For_OutOfRange_DateRange_Without_Dispatch( + int startMonth, + int startDay, + int endMonth, + int endDay) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + + var request = new ReplacePlayoutTemplatesRequest( + [new PlayoutTemplateItemRequest(0, 7, null, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]); + + IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + var problem = unprocessable.Value.ShouldBeOfType(); + problem.Status.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceTemplates_Should_Ignore_DateRange_Fields_When_LimitToDateRange_Is_False() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeTemplateViewModel(7)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeTemplateVm(1, 0, 7, null)]); + + // LimitToDateRange is false, so the out-of-range month/day here must not block the save. + var request = new ReplacePlayoutTemplatesRequest( + [new PlayoutTemplateItemRequest(0, 7, null, [], [], [], false, 0, 0, null, 13, 32, null)]); + + IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceTemplates_Should_Return_422_When_TemplateId_Unknown() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeTemplateViewModel(7)]); + + IActionResult result = await _controller.ReplaceTemplates( + 9, + new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(999, null)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceTemplates_Should_Return_422_When_DecoTemplateId_Unknown() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeTemplateViewModel(7)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.ReplaceTemplates( + 9, + new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, 999)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceTemplates_Should_Assign_Index_From_Order_And_Return_200() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeTemplateViewModel(7), MakeTemplateViewModel(8)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new DecoTemplateViewModel(5, 1, "G", "DT"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeTemplateVm(1, 0, 7, null), MakeTemplateVm(2, 1, 8, 5)]); + + IActionResult result = await _controller.ReplaceTemplates( + 9, + new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null), MakeTemplateRequest(8, 5)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => + c.PlayoutId == 9 && + c.Items.Count == 2 && + c.Items[0].Index == 0 && c.Items[0].TemplateId == 7 && c.Items[0].DecoTemplateId == null && + c.Items[1].Index == 1 && c.Items[1].TemplateId == 8 && c.Items[1].DecoTemplateId == 5), + Arg.Any()); + } + + private static PlayoutAlternateScheduleViewModel MakeAltVm(int id, int index, int programScheduleId) => + new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null); + + private static PlayoutAlternateScheduleItemRequest MakeAltRequest(int programScheduleId) => + new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null); + + private static ProgramScheduleViewModel MakeScheduleVm(int id) => + new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict); + + private static TemplateViewModel MakeTemplateViewModel(int id) => + new(id, 1, "Group", $"Template {id}"); + + private static PlayoutTemplateViewModel MakeTemplateVm(int id, int index, int templateId, int? decoTemplateId) => + new( + id, + new TemplateViewModel(templateId, 1, "Group", $"Template {templateId}"), + decoTemplateId is { } dtId ? new DecoTemplateViewModel(dtId, 1, "DGroup", $"Deco {dtId}") : null, + index, + [], + [], + [], + false, + 1, + 1, + null, + 12, + 31, + null); + + private static PlayoutTemplateItemRequest MakeTemplateRequest(int templateId, int? decoTemplateId) => + new(0, templateId, decoTemplateId, [], [], [], false, 1, 1, null, 12, 31, null); + private static PlayoutNameViewModel MakePlayout(int id) => new( id, @@ -458,7 +897,9 @@ public class PlayoutControllerTests "Schedule", string.Empty, null, - new PlayoutBuildStatus()); + new PlayoutBuildStatus(), + null, + null); private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) => PlayoutResponseModel.From( @@ -475,7 +916,9 @@ public class PlayoutControllerTests : new PlayoutBuildStatusResponseModel( vm.BuildStatus.LastBuild, vm.BuildStatus.Success, - vm.BuildStatus.Message)); + vm.BuildStatus.Message), + vm.DecoId, + vm.DecoName); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 00f0c441f..c6294b063 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -1,9 +1,11 @@ using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.Playouts; +using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Application.Scheduling; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; +using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Extensions; @@ -234,6 +236,200 @@ public class PlayoutController(IMediator mediator) : ControllerBase None: () => ApiResults.NotFoundProblem()); } + [HttpGet("/api/playouts/{id:int}/alternate-schedules", Name = "GetPlayoutAlternateSchedules")] + [Tags("Playouts")] + [EndpointSummary("Get a classic playout's alternate schedules")] + [EndpointDescription( + "Lists a Classic playout's alternate schedules in priority order (first = highest priority). The last " + + "entry is the catch-all default, and its schedule is the playout's default schedule; if no explicit " + + "catch-all exists the query synthesizes one from the playout's default schedule. Only valid for Classic " + + "playouts; other kinds return 422.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task GetAlternateSchedules(int id, CancellationToken cancellationToken) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + foreach (PlayoutNameViewModel playout in maybePlayout) + { + if (playout.ScheduleKind is not PlayoutScheduleKind.Classic) + { + return BaseError.New("[AlternateSchedules] are only valid for Classic playouts").ToErrorResult(); + } + } + + List items = + await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken); + return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList()); + } + + [HttpPut("/api/playouts/{id:int}/alternate-schedules")] + [Tags("Playouts")] + [EndpointSummary("Replace a classic playout's alternate schedules")] + [EndpointDescription( + "Replaces a Classic playout's alternate schedules. Items are ordered by priority: the first item is the " + + "highest priority and the last item is the catch-all default whose schedule becomes the playout's default " + + "schedule. Index is assigned from array order (the request body has no Index field). The list must contain " + + "at least one item, and every ProgramScheduleId must exist. Only valid for Classic playouts.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplaceAlternateSchedules( + int id, + [Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request, + CancellationToken cancellationToken) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + foreach (PlayoutNameViewModel playout in maybePlayout) + { + if (playout.ScheduleKind is not PlayoutScheduleKind.Classic) + { + return BaseError.New("[AlternateSchedules] are only valid for Classic playouts").ToErrorResult(); + } + } + + List items = request.Items ?? []; + if (items.Count == 0) + { + return BaseError.New("[Items] must contain at least one alternate schedule").ToErrorResult(); + } + + foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck))) + { + return error.ToErrorResult(); + } + + List schedules = + await mediator.Send(new GetAllProgramSchedules(), cancellationToken); + var scheduleIds = schedules.Select(s => s.Id).ToHashSet(); + var missingScheduleIds = items.Select(i => i.ProgramScheduleId).Distinct() + .Where(scheduleId => !scheduleIds.Contains(scheduleId)).ToList(); + if (missingScheduleIds.Count > 0) + { + return BaseError.New($"[ProgramScheduleId] {missingScheduleIds[0]} does not exist").ToErrorResult(); + } + + Either result = await mediator.Send(request.ToCommand(id), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + List refreshed = + await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken); + return (IActionResult)new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList()); + }); + } + + [HttpGet("/api/playouts/{id:int}/templates", Name = "GetPlayoutTemplates")] + [Tags("Playouts")] + [EndpointSummary("Get a block playout's templates")] + [EndpointDescription( + "Lists a Block playout's templates in priority order (first = highest priority). Each template optionally " + + "carries a deco template. Only valid for Block playouts; other kinds return 422.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task GetTemplates(int id, CancellationToken cancellationToken) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + foreach (PlayoutNameViewModel playout in maybePlayout) + { + if (playout.ScheduleKind is not PlayoutScheduleKind.Block) + { + return BaseError.New("[Templates] are only valid for Block playouts").ToErrorResult(); + } + } + + List items = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken); + return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList()); + } + + [HttpPut("/api/playouts/{id:int}/templates")] + [Tags("Playouts")] + [EndpointSummary("Replace a block playout's templates")] + [EndpointDescription( + "Replaces a Block playout's templates. Items are ordered by priority (first = highest priority); Index is " + + "assigned from array order (the request body has no Index field). Every TemplateId must exist, and any " + + "supplied DecoTemplateId must exist. An empty list clears all templates. Only valid for Block playouts.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplaceTemplates( + int id, + [Required] [FromBody] ReplacePlayoutTemplatesRequest request, + CancellationToken cancellationToken) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + foreach (PlayoutNameViewModel playout in maybePlayout) + { + if (playout.ScheduleKind is not PlayoutScheduleKind.Block) + { + return BaseError.New("[Templates] are only valid for Block playouts").ToErrorResult(); + } + } + + List items = request.Items ?? []; + + foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck))) + { + return error.ToErrorResult(); + } + + List templates = await mediator.Send(new GetAllTemplates(), cancellationToken); + var templateIds = templates.Select(t => t.Id).ToHashSet(); + var missingTemplateIds = items.Select(i => i.TemplateId).Distinct() + .Where(templateId => !templateIds.Contains(templateId)).ToList(); + if (missingTemplateIds.Count > 0) + { + return BaseError.New($"[TemplateId] {missingTemplateIds[0]} does not exist").ToErrorResult(); + } + + var decoTemplateIds = items.Where(i => i.DecoTemplateId.HasValue) + .Select(i => i.DecoTemplateId!.Value).Distinct().ToList(); + foreach (int decoTemplateId in decoTemplateIds) + { + Option maybeDecoTemplate = + await mediator.Send(new GetDecoTemplateById(decoTemplateId), cancellationToken); + if (maybeDecoTemplate.IsNone) + { + return BaseError.New($"[DecoTemplateId] {decoTemplateId} does not exist").ToErrorResult(); + } + } + + Option result = await mediator.Send(request.ToCommand(id), cancellationToken); + foreach (BaseError error in result) + { + return error.ToErrorResult(); + } + + List refreshed = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken); + return new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList()); + } + [HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")] [Tags("Playouts")] [EndpointSummary("Reset all playouts")] @@ -258,6 +454,53 @@ public class PlayoutController(IMediator mediator) : ControllerBase return result.ToDeletedResult(); } + // Guards against AlternateScheduleSelector crashing on out-of-range dates: it constructs + // `new DateTime(year, StartMonth, StartDay)` and only recovers from an overflowing *day* + // (rolling to the 1st of the next month); an out-of-range *month* throws again from inside + // that recovery path and is never caught. Only checked when LimitToDateRange is set, since + // the fields are ignored otherwise. + private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck( + PlayoutAlternateScheduleItemRequest item) => + (item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay); + + private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck( + PlayoutTemplateItemRequest item) => + (item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay); + + private static Option ValidateDateRanges( + IEnumerable<(bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay)> items) + { + foreach ((bool limitToDateRange, int startMonth, int startDay, int endMonth, int endDay) in items) + { + if (!limitToDateRange) + { + continue; + } + + if (startMonth is < 1 or > 12) + { + return Some(BaseError.New($"[StartMonth] {startMonth} must be between 1 and 12")); + } + + if (endMonth is < 1 or > 12) + { + return Some(BaseError.New($"[EndMonth] {endMonth} must be between 1 and 12")); + } + + if (startDay is < 1 or > 31) + { + return Some(BaseError.New($"[StartDay] {startDay} must be between 1 and 31")); + } + + if (endDay is < 1 or > 31) + { + return Some(BaseError.New($"[EndDay] {endDay} must be between 1 and 31")); + } + } + + return Option.None; + } + private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) => PlayoutResponseModel.From( vm.PlayoutId, @@ -268,7 +511,46 @@ public class PlayoutController(IMediator mediator) : ControllerBase vm.ScheduleName, vm.ScheduleFile, vm.DbDailyRebuildTime, - ToBuildStatus(vm.BuildStatus)); + ToBuildStatus(vm.BuildStatus), + vm.DecoId, + vm.DecoName); + + private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) => + new( + vm.Id, + vm.Index, + vm.ProgramScheduleId, + vm.DaysOfWeek, + vm.DaysOfMonth, + vm.MonthsOfYear, + vm.LimitToDateRange, + vm.StartMonth, + vm.StartDay, + vm.StartYear, + vm.EndMonth, + vm.EndDay, + vm.EndYear); + + private static PlayoutTemplateResponseModel ToResponse(PlayoutTemplateViewModel vm) => + new( + vm.Id, + vm.Index, + vm.Template.Id, + vm.Template.Name, + vm.Template.GroupName, + vm.DecoTemplate?.Id, + vm.DecoTemplate?.Name, + vm.DecoTemplate?.GroupName, + vm.DaysOfWeek, + vm.DaysOfMonth, + vm.MonthsOfYear, + vm.LimitToDateRange, + vm.StartMonth, + vm.StartDay, + vm.StartYear, + vm.EndMonth, + vm.EndDay, + vm.EndYear); private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) => new( diff --git a/ErsatzTV/Controllers/Api/Requests/PlayoutAlternateScheduleItemRequest.cs b/ErsatzTV/Controllers/Api/Requests/PlayoutAlternateScheduleItemRequest.cs new file mode 100644 index 000000000..e165eaafc --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/PlayoutAlternateScheduleItemRequest.cs @@ -0,0 +1,34 @@ +using ErsatzTV.Application.Playouts; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record PlayoutAlternateScheduleItemRequest( + int Id, + int ProgramScheduleId, + List DaysOfWeek, + List DaysOfMonth, + List MonthsOfYear, + bool LimitToDateRange, + int StartMonth, + int StartDay, + int? StartYear, + int EndMonth, + int EndDay, + int? EndYear) +{ + public ReplacePlayoutAlternateSchedule ToReplaceItem(int index) => + new( + Id, + index, + ProgramScheduleId, + DaysOfWeek ?? [], + DaysOfMonth ?? [], + MonthsOfYear ?? [], + LimitToDateRange, + StartMonth, + StartDay, + StartYear, + EndMonth, + EndDay, + EndYear); +} diff --git a/ErsatzTV/Controllers/Api/Requests/PlayoutTemplateItemRequest.cs b/ErsatzTV/Controllers/Api/Requests/PlayoutTemplateItemRequest.cs new file mode 100644 index 000000000..540b4f99a --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/PlayoutTemplateItemRequest.cs @@ -0,0 +1,36 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record PlayoutTemplateItemRequest( + int Id, + int TemplateId, + int? DecoTemplateId, + List DaysOfWeek, + List DaysOfMonth, + List MonthsOfYear, + bool LimitToDateRange, + int StartMonth, + int StartDay, + int? StartYear, + int EndMonth, + int EndDay, + int? EndYear) +{ + public ReplacePlayoutTemplate ToReplaceItem(int index) => + new( + Id, + index, + TemplateId, + DecoTemplateId, + DaysOfWeek ?? [], + DaysOfMonth ?? [], + MonthsOfYear ?? [], + LimitToDateRange, + StartMonth, + StartDay, + StartYear, + EndMonth, + EndDay, + EndYear); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ReplacePlayoutAlternateSchedulesRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplacePlayoutAlternateSchedulesRequest.cs new file mode 100644 index 000000000..b94b3ef89 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplacePlayoutAlternateSchedulesRequest.cs @@ -0,0 +1,17 @@ +using ErsatzTV.Application.Playouts; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ReplacePlayoutAlternateSchedulesRequest(List Items) +{ + // Index is assigned from array order: the first item is the highest priority and the last item + // is the lowest priority (the catch-all default whose schedule becomes the playout's default + // schedule). This mirrors the Blazor editor, which lists items top-to-bottom in priority order + // and writes the highest-Index item's schedule as the playout default. + public ReplacePlayoutAlternateScheduleItems ToCommand(int playoutId) => + new( + playoutId, + (Items ?? []) + .Select((item, index) => item.ToReplaceItem(index)) + .ToList()); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ReplacePlayoutTemplatesRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplacePlayoutTemplatesRequest.cs new file mode 100644 index 000000000..73bce1d8c --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplacePlayoutTemplatesRequest.cs @@ -0,0 +1,14 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ReplacePlayoutTemplatesRequest(List Items) +{ + // Index is assigned from array order (top-to-bottom priority), mirroring the Blazor editor. + public ReplacePlayoutTemplateItems ToCommand(int playoutId) => + new( + playoutId, + (Items ?? []) + .Select((item, index) => item.ToReplaceItem(index)) + .ToList()); +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 0def3b884..77259c594 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -5903,6 +5903,418 @@ } } }, + "/api/playouts/{id}/alternate-schedules": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "Get a classic playout's alternate schedules", + "description": "Lists a Classic playout's alternate schedules in priority order (first = highest priority). The last entry is the catch-all default, and its schedule is the playout's default schedule; if no explicit catch-all exists the query synthesizes one from the playout's default schedule. Only valid for Classic playouts; other kinds return 422.", + "operationId": "GetPlayoutAlternateSchedules", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Playouts" + ], + "summary": "Replace a classic playout's alternate schedules", + "description": "Replaces a Classic playout's alternate schedules. Items are ordered by priority: the first item is the highest priority and the last item is the catch-all default whose schedule becomes the playout's default schedule. Index is assigned from array order (the request body has no Index field). The list must contain at least one item, and every ProgramScheduleId must exist. Only valid for Classic playouts.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlayoutAlternateSchedulesRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlayoutAlternateSchedulesRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlayoutAlternateSchedulesRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlayoutAlternateSchedulesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/api/playouts/{id}/templates": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "Get a block playout's templates", + "description": "Lists a Block playout's templates in priority order (first = highest priority). Each template optionally carries a deco template. Only valid for Block playouts; other kinds return 422.", + "operationId": "GetPlayoutTemplates", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutTemplateResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutTemplateResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutTemplateResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Playouts" + ], + "summary": "Replace a block playout's templates", + "description": "Replaces a Block playout's templates. Items are ordered by priority (first = highest priority); Index is assigned from array order (the request body has no Index field). Every TemplateId must exist, and any supplied DecoTemplateId must exist. An empty list clears all templates. Only valid for Block playouts.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlayoutTemplatesRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlayoutTemplatesRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlayoutTemplatesRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlayoutTemplatesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutTemplateResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutTemplateResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutTemplateResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, "/api/playouts/reset-all": { "post": { "tags": [ @@ -12983,6 +13395,9 @@ } } }, + "DayOfWeek": { + "type": "integer" + }, "DecoBreakContentRequest": { "required": [ "id", @@ -14889,6 +15304,180 @@ } } }, + "PlayoutAlternateScheduleItemRequest": { + "required": [ + "id", + "programScheduleId", + "daysOfWeek", + "daysOfMonth", + "monthsOfYear", + "limitToDateRange", + "startMonth", + "startDay", + "startYear", + "endMonth", + "endDay", + "endYear" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "programScheduleId": { + "type": "integer", + "format": "int32" + }, + "daysOfWeek": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/DayOfWeek" + } + }, + "daysOfMonth": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "monthsOfYear": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "limitToDateRange": { + "type": "boolean" + }, + "startMonth": { + "type": "integer", + "format": "int32" + }, + "startDay": { + "type": "integer", + "format": "int32" + }, + "startYear": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "endMonth": { + "type": "integer", + "format": "int32" + }, + "endDay": { + "type": "integer", + "format": "int32" + }, + "endYear": { + "type": [ + "null", + "integer" + ], + "format": "int32" + } + } + }, + "PlayoutAlternateScheduleResponseModel": { + "required": [ + "id", + "index", + "programScheduleId", + "daysOfWeek", + "daysOfMonth", + "monthsOfYear", + "limitToDateRange", + "startMonth", + "startDay", + "startYear", + "endMonth", + "endDay", + "endYear" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "index": { + "type": "integer", + "format": "int32" + }, + "programScheduleId": { + "type": "integer", + "format": "int32" + }, + "daysOfWeek": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DayOfWeek" + } + }, + "daysOfMonth": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "monthsOfYear": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "limitToDateRange": { + "type": "boolean" + }, + "startMonth": { + "type": "integer", + "format": "int32" + }, + "startDay": { + "type": "integer", + "format": "int32" + }, + "startYear": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "endMonth": { + "type": "integer", + "format": "int32" + }, + "endDay": { + "type": "integer", + "format": "int32" + }, + "endYear": { + "type": [ + "null", + "integer" + ], + "format": "int32" + } + } + }, "PlayoutBuildMode": { "enum": [ "Continue", @@ -15028,7 +15617,9 @@ "scheduleName", "scheduleFile", "dailyRebuildTime", - "buildStatus" + "buildStatus", + "decoId", + "decoName" ], "type": "object", "properties": { @@ -15073,6 +15664,19 @@ "$ref": "#/components/schemas/PlayoutBuildStatusResponseModel" } ] + }, + "decoId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "decoName": { + "type": [ + "null", + "string" + ] } } }, @@ -15108,6 +15712,218 @@ } } }, + "PlayoutTemplateItemRequest": { + "required": [ + "id", + "templateId", + "decoTemplateId", + "daysOfWeek", + "daysOfMonth", + "monthsOfYear", + "limitToDateRange", + "startMonth", + "startDay", + "startYear", + "endMonth", + "endDay", + "endYear" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "templateId": { + "type": "integer", + "format": "int32" + }, + "decoTemplateId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "daysOfWeek": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/DayOfWeek" + } + }, + "daysOfMonth": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "monthsOfYear": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "limitToDateRange": { + "type": "boolean" + }, + "startMonth": { + "type": "integer", + "format": "int32" + }, + "startDay": { + "type": "integer", + "format": "int32" + }, + "startYear": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "endMonth": { + "type": "integer", + "format": "int32" + }, + "endDay": { + "type": "integer", + "format": "int32" + }, + "endYear": { + "type": [ + "null", + "integer" + ], + "format": "int32" + } + } + }, + "PlayoutTemplateResponseModel": { + "required": [ + "id", + "index", + "templateId", + "templateName", + "templateGroupName", + "decoTemplateId", + "decoTemplateName", + "decoTemplateGroupName", + "daysOfWeek", + "daysOfMonth", + "monthsOfYear", + "limitToDateRange", + "startMonth", + "startDay", + "startYear", + "endMonth", + "endDay", + "endYear" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "index": { + "type": "integer", + "format": "int32" + }, + "templateId": { + "type": "integer", + "format": "int32" + }, + "templateName": { + "type": "string" + }, + "templateGroupName": { + "type": "string" + }, + "decoTemplateId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "decoTemplateName": { + "type": [ + "null", + "string" + ] + }, + "decoTemplateGroupName": { + "type": [ + "null", + "string" + ] + }, + "daysOfWeek": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DayOfWeek" + } + }, + "daysOfMonth": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "monthsOfYear": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "limitToDateRange": { + "type": "boolean" + }, + "startMonth": { + "type": "integer", + "format": "int32" + }, + "startDay": { + "type": "integer", + "format": "int32" + }, + "startYear": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "endMonth": { + "type": "integer", + "format": "int32" + }, + "endDay": { + "type": "integer", + "format": "int32" + }, + "endYear": { + "type": [ + "null", + "integer" + ], + "format": "int32" + } + } + }, "ProblemDetails": { "type": "object", "properties": { @@ -15589,6 +16405,40 @@ } } }, + "ReplacePlayoutAlternateSchedulesRequest": { + "required": [ + "items" + ], + "type": "object", + "properties": { + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/PlayoutAlternateScheduleItemRequest" + } + } + } + }, + "ReplacePlayoutTemplatesRequest": { + "required": [ + "items" + ], + "type": "object", + "properties": { + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/PlayoutTemplateItemRequest" + } + } + } + }, "ReplaceScheduleItemsRequest": { "required": [ "items" diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 7801f14a7..5491331b0 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1674,6 +1674,108 @@ describe('ChicoryTV SPA scaffold', () => { expect(fetchCount('/api/playouts')).toBe(2); }); + // Regression coverage for the live-E2E finding: clicking "Alternate schedules" / "Templates" + // used to update the URL via navigateToPath's pushState + synthetic popstate but never swap + // the rendered screen, because routeFromLocation() returns the SAME ScreenRoute object + // reference for /app/playouts and every /app/playouts/{id}/* sub-path, so React's + // setActiveRoute(sameRef) bailed via Object.is and ScreenContent never re-ran. The fix (see + // PlayoutsRouteScreen in App.tsx) mirrors BlocksScreen/TemplatesScreen/DecosScreen: it owns its + // own pathname state and popstate listener instead of relying on the shell's activeRoute. + it('client-side navigates into and back out of the alternate-schedules editor without a reload', async () => { + mockDashboardApi({ + playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }), + playoutItems: [playoutItem()], + playouts: { page: [listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' })], totalCount: 1 }, + schedules: [{ id: 7, name: 'Alpha' }], + playoutAlternateSchedules: [ + { + id: 1, + index: 0, + programScheduleId: 7, + daysOfWeek: [], + daysOfMonth: [], + monthsOfYear: [], + limitToDateRange: false, + startMonth: 1, + startDay: 1, + startYear: null, + endMonth: 12, + endDay: 31, + endYear: null + } + ] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Alternate schedules' })); + + // Synchronous assertion (no findBy/await): the editor's initial render happens in the same + // tick as the click, before its data fetch resolves - proving the screen actually swapped + // instead of silently staying on the Playouts list while only the URL changed. + expect(screen.getByText('Loading alternate schedules…')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Retro Cartoons' })).not.toBeInTheDocument(); + expect(window.location.pathname).toBe('/app/playouts/20/alternate-schedules'); + + fireEvent.click(await screen.findByRole('button', { name: 'All playouts' })); + + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + expect(window.location.pathname).toBe('/app/playouts'); + }); + + it('client-side navigates into and back out of the block-templates editor without a reload', async () => { + mockDashboardApi({ + playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleKind: 'Block' }), + playoutItems: [playoutItem()], + playouts: { + page: [listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleKind: 'Block' })], + totalCount: 1 + }, + templates: [{ id: 3, templateGroupId: 1, groupName: 'Grp', name: 'Weekdays' }], + playoutTemplateItems: [ + { + id: 1, + index: 0, + templateId: 3, + templateName: 'Weekdays', + templateGroupName: 'Grp', + decoTemplateId: null, + decoTemplateName: null, + decoTemplateGroupName: null, + daysOfWeek: [], + daysOfMonth: [], + monthsOfYear: [], + limitToDateRange: false, + startMonth: 1, + startDay: 1, + startYear: null, + endMonth: 12, + endDay: 31, + endYear: null + } + ] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + fireEvent.click(await screen.findByRole('button', { name: 'Templates' })); + + expect(screen.getByText('Loading playout templates…')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Retro Cartoons' })).not.toBeInTheDocument(); + expect(window.location.pathname).toBe('/app/playouts/20/templates'); + + fireEvent.click(await screen.findByRole('button', { name: 'All playouts' })); + + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + expect(window.location.pathname).toBe('/app/playouts'); + }); + it('shows the Playouts loading state', async () => { vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { const path = input.toString(); @@ -3431,6 +3533,10 @@ function mockDashboardApi({ channelTemplates = [], createTemplateResponse = null, defaultChannelTemplate = null, + playoutAlternateSchedules = [], + playoutTemplateItems = [], + templates = [], + decoTemplates = [], ffmpegProfiles = [], fromLineupFailure = null, fromLineupResponse = { channelId: 1, playlistId: null, playoutId: 3, programScheduleId: 2 }, @@ -3492,6 +3598,10 @@ function mockDashboardApi({ channelTemplates?: unknown[]; createTemplateResponse?: unknown; defaultChannelTemplate?: unknown; + playoutAlternateSchedules?: unknown[]; + playoutTemplateItems?: unknown[]; + templates?: unknown[]; + decoTemplates?: unknown[]; ffmpegProfiles?: unknown[]; fromLineupFailure?: { detail?: string; status?: number; title?: string } | null; fromLineupResponse?: unknown; @@ -3772,6 +3882,22 @@ function mockDashboardApi({ return Promise.resolve(new Response(null, { status: 202 })); } + if (path.match(/^\/api\/playouts\/\d+\/alternate-schedules$/)) { + return Promise.resolve(jsonResponse(playoutAlternateSchedules)); + } + + if (path.match(/^\/api\/playouts\/\d+\/templates$/)) { + return Promise.resolve(jsonResponse(playoutTemplateItems)); + } + + if (path === '/api/templates') { + return Promise.resolve(jsonResponse(templates)); + } + + if (path === '/api/deco-templates') { + return Promise.resolve(jsonResponse(decoTemplates)); + } + const itemsMatch = path.match(/^\/api\/playouts\/\d+\/items(\?(?.*))?$/); if (itemsMatch) { diff --git a/web/src/App.tsx b/web/src/App.tsx index f697bd3fb..ffd4fbcf6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -78,7 +78,11 @@ import { BlocksScreen } from './screens/BlocksScreen'; import { DecosScreen } from './screens/DecosScreen'; import { DecoTemplatesScreen } from './screens/DecoTemplatesScreen'; import { TemplatesScreen } from './screens/TemplatesScreen'; -import { navigateToPath } from './routing'; +import { + PlayoutAlternateSchedulesScreen, + PlayoutTemplatesEditorScreen +} from './screens/PlayoutScheduleEditors'; +import { navigateToPath, parsePlayoutSubRoute } from './routing'; import { Badge, Button, @@ -106,12 +110,14 @@ import { bulkRenumberChannels, createPlayout, deleteChannel, + getDecos, messageFromError, addScheduleItem, deleteScheduleItem, getSchedules, resetAllPlayouts, replaceScheduleItems, + updatePlayoutDefaultDeco, updatePlayoutDetails, usePlayoutsScreenQuery, useScheduleScreenQuery, @@ -128,6 +134,7 @@ import { type ChannelGuideProgramme, type CreatePlayoutRequest, type DashboardChannel, + type DecoListItem, type SchedulePickerData, type DashboardChannelState, type DashboardHealthQueryState, @@ -315,6 +322,8 @@ const routes: ScreenRoute[] = [ allowSubPaths: true }, { + // The alternate-schedules / templates editors live at sub-paths + // (/app/playouts/{id}/alternate-schedules, /app/playouts/{id}/templates). id: 'playouts', path: '/app/playouts', label: 'Playouts', @@ -324,7 +333,8 @@ const routes: ScreenRoute[] = [ icon: