wip: partial implementation salvaged from interrupted workflow run

Untrusted draft — no build/test had run yet. Review before building on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:27:36 +02:00
co-authored by Claude Fable 5
parent d1dfe6eb5a
commit 49a6d4eebd
10 changed files with 271 additions and 8 deletions
@@ -16,6 +16,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> 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,
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutItemsResponseModel(
int TotalCount,
List<PlayoutItemResponseModel> Page);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutsResponseModel(
int TotalCount,
List<PlayoutListItemResponseModel> Page);
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutBuildStatusResponseModel(
DateTimeOffset LastBuild,
bool Success,
string Message);
@@ -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);
@@ -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);
@@ -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);
}
@@ -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<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.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<GetPagedPlayouts>(q => q.Query == "q" && q.PageNum == 2 && q.PageSize == 25),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetAll_Should_Emit_Null_BuildStatus_When_Absent()
{
PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = null };
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.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<FillerKind>.None);
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
.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<GetFuturePlayoutItemsById>(q =>
q.PlayoutId == 9 && q.ShowFiller && q.PageNum == 1 && q.PageSize == 10),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetWarningsCount_Should_Return_Count()
{
_mediator.Send(Arg.Any<GetPlayoutWarningsCount>(), Arg.Any<CancellationToken>())
.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<AcceptedResult>();
await _mediator.Received(1).Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>());
}
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)
{
+28 -3
View File
@@ -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<IBackgroundServiceRequest> 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<IActionResult> ResetPlayout(string channelNumber)
public async Task<IActionResult> ResetPlayout(
string channelNumber,
[FromQuery] PlayoutBuildMode? mode,
CancellationToken cancellationToken)
{
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
Option<int> 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<PlayoutBuildMode> DefaultResetMode(int playoutId, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> 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);
}
}
+86 -1
View File
@@ -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<PagedPlayoutsResponseModel> 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<int> 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<PagedPlayoutItemsResponseModel> 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<IActionResult> 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());
}