From 49a6d4eebd774b4cc985de0eefd8339b5b5ed8b8 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:27:36 +0200 Subject: [PATCH] wip: partial implementation salvaged from interrupted workflow run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Untrusted draft — no build/test had run yet. Review before building on it. Co-Authored-By: Claude Fable 5 --- .../Playouts/Queries/GetPlayoutByIdHandler.cs | 1 + .../PagedPlayoutItemsResponseModel.cs | 5 + .../Playouts/PagedPlayoutsResponseModel.cs | 5 + .../PlayoutBuildStatusResponseModel.cs | 6 + .../Api/Playouts/PlayoutItemResponseModel.cs | 10 ++ .../Playouts/PlayoutListItemResponseModel.cs | 12 ++ .../Api/Playouts/PlayoutResponseModel.cs | 9 +- .../Controllers/PlayoutControllerTests.cs | 113 +++++++++++++++++- ErsatzTV/Controllers/Api/ChannelController.cs | 31 ++++- ErsatzTV/Controllers/Api/PlayoutController.cs | 87 +++++++++++++- 10 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs index e47f91433..b765ff7dd 100644 --- a/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutByIdHandler.cs @@ -16,6 +16,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory dbContextFactory .AsNoTracking() .Include(p => p.ProgramSchedule) .Include(p => p.Channel) + .Include(p => p.BuildStatus) .SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken) .MapT(p => new PlayoutNameViewModel( p.Id, diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs new file mode 100644 index 000000000..be6249ef1 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutItemsResponseModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Api.Playouts; + +public record PagedPlayoutItemsResponseModel( + int TotalCount, + List Page); diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs new file mode 100644 index 000000000..5473e67d2 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutsResponseModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Api.Playouts; + +public record PagedPlayoutsResponseModel( + int TotalCount, + List Page); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs new file mode 100644 index 000000000..5410c9d23 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutBuildStatusResponseModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutBuildStatusResponseModel( + DateTimeOffset LastBuild, + bool Success, + string Message); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs new file mode 100644 index 000000000..ab9c7104e --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutItemResponseModel( + string Title, + DateTimeOffset Start, + DateTimeOffset Finish, + string Duration, + FillerKind? FillerKind); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs new file mode 100644 index 000000000..54ac24d32 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutListItemResponseModel( + int Id, + string ChannelNumber, + string ChannelName, + PlayoutScheduleKind ScheduleKind, + string ScheduleName, + TimeSpan? DailyRebuildTime, + PlayoutBuildStatusResponseModel BuildStatus); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs index a80fd6ed0..32f949a2b 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs @@ -10,7 +10,8 @@ public record PlayoutResponseModel( ChannelPlayoutMode PlayoutMode, string ScheduleName, string ScheduleFile, - TimeSpan? DailyRebuildTime) + TimeSpan? DailyRebuildTime, + PlayoutBuildStatusResponseModel BuildStatus) { public static PlayoutResponseModel From( int id, @@ -20,7 +21,8 @@ public record PlayoutResponseModel( ChannelPlayoutMode playoutMode, string scheduleName, string scheduleFile, - TimeSpan? dailyRebuildTime) => + TimeSpan? dailyRebuildTime, + PlayoutBuildStatusResponseModel buildStatus) => new( id, scheduleKind, @@ -29,5 +31,6 @@ public record PlayoutResponseModel( playoutMode, scheduleName, scheduleFile, - dailyRebuildTime); + dailyRebuildTime, + buildStatus); } diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index 0726e9a6f..9703f2e8b 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -5,6 +5,7 @@ using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Errors; using LanguageExt; using MediatR; @@ -34,8 +35,12 @@ public class PlayoutControllerTests [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { + ShouldHaveActionRoute(nameof(PlayoutController.GetAll), "GET", "/api/playouts"); ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/playouts/{id:int}"); + 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.ResetAll), "POST", "/api/playouts/reset-all"); ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}"); } @@ -162,6 +167,106 @@ public class PlayoutControllerTests problem.Title.ShouldBe("Resource not found"); } + [Test] + public async Task GetAll_Should_Project_Paged_List_With_BuildStatus() + { + var buildStatus = new PlayoutBuildStatus + { + LastBuild = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.Zero), + Success = false, + Message = "boom" + }; + PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = buildStatus }; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutsViewModel(1, [vm])); + + PagedPlayoutsResponseModel result = await _controller.GetAll("q", 2, 25, CancellationToken.None); + + result.TotalCount.ShouldBe(1); + PlayoutListItemResponseModel item = result.Page.Single(); + item.Id.ShouldBe(9); + item.ChannelNumber.ShouldBe("101"); + item.ChannelName.ShouldBe("Channel"); + item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic); + item.ScheduleName.ShouldBe("Schedule"); + item.BuildStatus.ShouldNotBeNull(); + item.BuildStatus.Success.ShouldBeFalse(); + item.BuildStatus.Message.ShouldBe("boom"); + item.BuildStatus.LastBuild.ShouldBe(buildStatus.LastBuild); + + await _mediator.Received(1).Send( + Arg.Is(q => q.Query == "q" && q.PageNum == 2 && q.PageSize == 25), + Arg.Any()); + } + + [Test] + public async Task GetAll_Should_Emit_Null_BuildStatus_When_Absent() + { + PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = null }; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutsViewModel(1, [vm])); + + PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None); + + result.Page.Single().BuildStatus.ShouldBeNull(); + } + + [Test] + public async Task GetItems_Should_Project_Items_And_Null_FillerKind_For_Gaps() + { + var item = new PlayoutItemViewModel( + "Movie", + new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), + "1:00:00", + string.Empty, + Some(FillerKind.MidRoll)); + var gap = new PlayoutItemViewModel( + "UNSCHEDULED", + new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero), + "30:00", + string.Empty, + Option.None); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutItemsViewModel(2, [item, gap])); + + PagedPlayoutItemsResponseModel result = + await _controller.GetItems(9, showFiller: true, 1, 10, CancellationToken.None); + + result.TotalCount.ShouldBe(2); + result.Page[0].Title.ShouldBe("Movie"); + result.Page[0].Duration.ShouldBe("1:00:00"); + result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll); + result.Page[1].Title.ShouldBe("UNSCHEDULED"); + result.Page[1].FillerKind.ShouldBeNull(); + + await _mediator.Received(1).Send( + Arg.Is(q => + q.PlayoutId == 9 && q.ShowFiller && q.PageNum == 1 && q.PageSize == 10), + Arg.Any()); + } + + [Test] + public async Task GetWarningsCount_Should_Return_Count() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(7); + + int result = await _controller.GetWarningsCount(CancellationToken.None); + + result.ShouldBe(7); + } + + [Test] + public async Task ResetAll_Should_Return_202_And_Send_Command() + { + IActionResult result = await _controller.ResetAll(CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); + } + private static PlayoutNameViewModel MakePlayout(int id) => new( id, @@ -183,7 +288,13 @@ public class PlayoutControllerTests vm.PlayoutMode, vm.ScheduleName, vm.ScheduleFile, - vm.DbDailyRebuildTime); + vm.DbDailyRebuildTime, + vm.BuildStatus is null + ? null + : new PlayoutBuildStatusResponseModel( + vm.BuildStatus.LastBuild, + vm.BuildStatus.Success, + vm.BuildStatus.Message)); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 6220adfa5..76577ca10 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Scheduling; using ErsatzTV.Extensions; using MediatR; @@ -90,18 +91,42 @@ public class ChannelController(ChannelWriter workerCh [HttpPost("/api/channels/{channelNumber}/playout/reset")] [Tags("Channels")] [EndpointSummary("Reset channel playout")] + [EndpointDescription( + "When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection " + + "progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " + + "Pass mode to force a specific PlayoutBuildMode.")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] - public async Task ResetPlayout(string channelNumber) + public async Task ResetPlayout( + string channelNumber, + [FromQuery] PlayoutBuildMode? mode, + CancellationToken cancellationToken) { - Option maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber)); + Option maybePlayoutId = + await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken); foreach (int playoutId in maybePlayoutId) { - await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset)); + PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken); + await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken); return new OkResult(); } return ApiResults.NotFoundProblem(); } + + // Match Blazor's Playouts.razor reset semantics: classic playouts refresh (preserve progress), + // every other kind resets from scratch. + private async Task DefaultResetMode(int playoutId, CancellationToken cancellationToken) + { + Option maybePlayout = + await mediator.Send(new GetPlayoutById(playoutId), cancellationToken); + return maybePlayout.Match( + Some: vm => vm.ScheduleKind switch + { + PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh, + _ => PlayoutBuildMode.Reset + }, + None: () => PlayoutBuildMode.Reset); + } } diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 833ce778b..302018279 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; +using ErsatzTV.Core.Domain; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; @@ -13,6 +14,32 @@ namespace ErsatzTV.Controllers.Api; [ApiController] public class PlayoutController(IMediator mediator) : ControllerBase { + [HttpGet("/api/playouts", Name = "GetPlayouts")] + [Tags("Playouts")] + [EndpointSummary("List playouts")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedPlayoutsResponseModel), StatusCodes.Status200OK)] + public async Task GetAll( + [FromQuery] string query = "", + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + PagedPlayoutsViewModel result = + await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken); + return new PagedPlayoutsResponseModel( + result.TotalCount, + result.Page.Map(ToListItemResponse).ToList()); + } + + [HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")] + [Tags("Playouts")] + [EndpointSummary("Count playouts with a failed build")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + public async Task GetWarningsCount(CancellationToken cancellationToken) => + await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken); + [HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")] [Tags("Playouts")] [EndpointSummary("Get a playout by id")] @@ -25,6 +52,26 @@ public class PlayoutController(IMediator mediator) : ControllerBase return result.Map(ToResponse).ToGetResult(); } + [HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")] + [Tags("Playouts")] + [EndpointSummary("Get upcoming items (and unscheduled gaps) for a playout")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedPlayoutItemsResponseModel), StatusCodes.Status200OK)] + public async Task GetItems( + int id, + [FromQuery] bool showFiller = false, + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + PagedPlayoutItemsViewModel result = await mediator.Send( + new GetFuturePlayoutItemsById(id, showFiller, pageNum, pageSize), + cancellationToken); + return new PagedPlayoutItemsResponseModel( + result.TotalCount, + result.Page.Map(ToItemResponse).ToList()); + } + [HttpPost("/api/playouts")] [Tags("Playouts")] [EndpointSummary("Create a classic playout")] @@ -49,6 +96,17 @@ public class PlayoutController(IMediator mediator) : ControllerBase }); } + [HttpPost("/api/playouts/reset-all")] + [Tags("Playouts")] + [EndpointSummary("Reset all playouts")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + public async Task ResetAll(CancellationToken cancellationToken) + { + await mediator.Send(new ResetAllPlayouts(), cancellationToken); + return Accepted(); + } + [HttpDelete("/api/playouts/{id:int}")] [Tags("Playouts")] [EndpointSummary("Delete a playout")] @@ -71,5 +129,32 @@ public class PlayoutController(IMediator mediator) : ControllerBase vm.PlayoutMode, vm.ScheduleName, vm.ScheduleFile, - vm.DbDailyRebuildTime); + vm.DbDailyRebuildTime, + ToBuildStatus(vm.BuildStatus)); + + private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) => + new( + vm.PlayoutId, + vm.ChannelNumber, + vm.ChannelName, + vm.ScheduleKind, + vm.ScheduleName, + vm.DbDailyRebuildTime, + ToBuildStatus(vm.BuildStatus)); + + private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) => + buildStatus is null + ? null + : new PlayoutBuildStatusResponseModel( + buildStatus.LastBuild, + buildStatus.Success, + buildStatus.Message); + + private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) => + new( + vm.Title, + vm.Start, + vm.Finish, + vm.Duration, + vm.FillerKind.ToNullable()); }