Merge branch 'feat/235-s3-playouts' into feat/235-async-contract

This commit is contained in:
2026-07-11 18:02:58 +02:00
11 changed files with 188 additions and 29 deletions
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record ResetAllPlayouts : IRequest;
public record ResetAllPlayouts : IRequest<ResetAllPlayoutsResult>;
@@ -11,33 +11,49 @@ public class ResetAllPlayoutsHandler(
IEntityLocker locker,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ResetAllPlayouts>
: IRequestHandler<ResetAllPlayouts, ResetAllPlayoutsResult>
{
public async Task Handle(ResetAllPlayouts request, CancellationToken cancellationToken)
public async Task<ResetAllPlayoutsResult> Handle(
ResetAllPlayouts request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var queued = new List<int>();
var skippedLocked = new List<int>();
var skippedUnsupported = new List<int>();
foreach (Playout playout in await dbContext.Playouts.ToListAsync(cancellationToken))
{
switch (playout.ScheduleKind)
{
case PlayoutScheduleKind.Classic:
if (!locker.IsPlayoutLocked(playout.Id))
if (locker.IsPlayoutLocked(playout.Id))
{
skippedLocked.Add(playout.Id);
}
else
{
await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh),
cancellationToken);
queued.Add(playout.Id);
}
break;
case PlayoutScheduleKind.Block:
case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted:
if (!locker.IsPlayoutLocked(playout.Id))
if (locker.IsPlayoutLocked(playout.Id))
{
skippedLocked.Add(playout.Id);
}
else
{
await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
queued.Add(playout.Id);
}
break;
@@ -45,8 +61,11 @@ public class ResetAllPlayoutsHandler(
case PlayoutScheduleKind.None:
default:
// external json cannot be reset
skippedUnsupported.Add(playout.Id);
continue;
}
}
return new ResetAllPlayoutsResult(queued, skippedLocked, skippedUnsupported);
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Application.Playouts;
public record ResetAllPlayoutsResult(
List<int> QueuedPlayoutIds,
List<int> SkippedLocked,
List<int> SkippedUnsupported);
@@ -14,7 +14,8 @@ public record PlayoutResponseModel(
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus,
int? DecoId,
string? DecoName)
string? DecoName,
bool IsLocked)
{
public static PlayoutResponseModel From(
int id,
@@ -27,7 +28,8 @@ public record PlayoutResponseModel(
TimeSpan? dailyRebuildTime,
PlayoutBuildStatusResponseModel? buildStatus,
int? decoId,
string? decoName) =>
string? decoName,
bool isLocked) =>
new(
id,
scheduleKind,
@@ -39,5 +41,6 @@ public record PlayoutResponseModel(
dailyRebuildTime,
buildStatus,
decoId,
decoName);
decoName,
isLocked);
}
@@ -0,0 +1,7 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record ResetAllPlayoutsResponseModel(
List<int> QueuedPlayoutIds,
List<int> SkippedLocked,
List<int> SkippedUnsupported);
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Channel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Application.Playouts;
[TestFixture]
public class ResetAllPlayoutsHandlerTests
{
private InMemoryTvContext _db = null!;
private Channel<IBackgroundServiceRequest> _worker = null!;
private IEntityLocker _entityLocker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
_entityLocker = Substitute.For<IEntityLocker>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private ResetAllPlayoutsHandler CreateHandler() =>
new(_entityLocker, _worker.Writer, _db.Factory);
private async Task<int> SeedPlayout(PlayoutScheduleKind kind)
{
await using TvContext context = _db.CreateContext();
var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
context.Playouts.Add(playout);
await context.SaveChangesAsync();
return playout.Id;
}
[Test]
public async Task Handle_Should_Queue_Eligible_And_Report_Skipped()
{
int classicId = await SeedPlayout(PlayoutScheduleKind.Classic);
int blockId = await SeedPlayout(PlayoutScheduleKind.Block);
int lockedId = await SeedPlayout(PlayoutScheduleKind.Sequential);
int externalJsonId = await SeedPlayout(PlayoutScheduleKind.ExternalJson);
int noneId = await SeedPlayout(PlayoutScheduleKind.None);
_entityLocker.IsPlayoutLocked(lockedId).Returns(true);
ResetAllPlayoutsResult result =
await CreateHandler().Handle(new ResetAllPlayouts(), CancellationToken.None);
// eligible, unlocked playouts are queued
result.QueuedPlayoutIds.ShouldBe(new List<int> { classicId, blockId }, ignoreOrder: true);
// locked playout lands in SkippedLocked, not queued
result.SkippedLocked.ShouldBe(new List<int> { lockedId });
// ExternalJson + None land in SkippedUnsupported
result.SkippedUnsupported.ShouldBe(new List<int> { externalJsonId, noneId }, ignoreOrder: true);
// exactly one BuildPlayout message per queued playout was enqueued
var enqueued = new List<int>();
while (_worker.Reader.TryRead(out IBackgroundServiceRequest? request))
{
var build = request.ShouldBeOfType<BuildPlayout>();
enqueued.Add(build.PlayoutId);
}
enqueued.ShouldBe(new List<int> { classicId, blockId }, ignoreOrder: true);
}
}
@@ -407,7 +407,7 @@ public class ChannelControllerTests
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
result.ShouldBeOfType<OkResult>();
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
var buildPlayout = request.ShouldBeOfType<BuildPlayout>();
buildPlayout.PlayoutId.ShouldBe(9);
@@ -436,7 +436,7 @@ public class ChannelControllerTests
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
result.ShouldBeOfType<OkResult>();
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
request.ShouldBeOfType<BuildPlayout>().Mode.ShouldBe(PlayoutBuildMode.Continue);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
@@ -527,6 +527,21 @@ public class PlayoutControllerTests
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(ToResponse(vm));
}
[Test]
public async Task GetById_Should_Expose_IsLocked_From_EntityLocker()
{
PlayoutNameViewModel vm = MakePlayout(9);
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(vm));
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.GetById(9, CancellationToken.None);
var body = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlayoutResponseModel>();
body.IsLocked.ShouldBeTrue();
body.ShouldBe(ToResponse(vm, isLocked: true));
}
[Test]
public async Task GetById_Should_Return_404_For_None_With_ProblemDetails()
{
@@ -693,9 +708,17 @@ public class PlayoutControllerTests
[Test]
public async Task ResetAll_Should_Return_202_And_Send_Command()
{
_mediator.Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new ResetAllPlayoutsResult([1, 2], [3], [4]));
IActionResult result = await _controller.ResetAll(CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>();
var accepted = result.ShouldBeOfType<AcceptedResult>();
accepted.StatusCode.ShouldBe(202);
var body = accepted.Value.ShouldBeOfType<ResetAllPlayoutsResponseModel>();
body.QueuedPlayoutIds.ShouldBe(new List<int> { 1, 2 });
body.SkippedLocked.ShouldBe(new List<int> { 3 });
body.SkippedUnsupported.ShouldBe(new List<int> { 4 });
await _mediator.Received(1).Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>());
}
@@ -1286,7 +1309,7 @@ public class PlayoutControllerTests
null,
null);
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) =>
PlayoutResponseModel.From(
vm.PlayoutId,
vm.ScheduleKind,
@@ -1303,7 +1326,8 @@ public class PlayoutControllerTests
vm.BuildStatus.Success,
vm.BuildStatus.Message),
vm.DecoId,
vm.DecoName);
vm.DecoName,
isLocked);
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
@@ -211,7 +211,7 @@ public class ChannelController(
"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(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResetPlayout(
@@ -233,7 +233,7 @@ public class ChannelController(
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
return new OkResult();
return new AcceptedResult();
}
return ApiResults.NotFoundProblem();
+19 -10
View File
@@ -69,7 +69,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> result = await mediator.Send(new GetPlayoutById(id), cancellationToken);
return result.Map(ToResponse).ToGetResult();
return result.Map(vm => ToResponse(vm, entityLocker.IsPlayoutLocked(id))).ToGetResult();
}
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
@@ -128,7 +128,9 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
Option<PlayoutNameViewModel> playout =
await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken);
return playout.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/playouts/{vm.PlayoutId}", ToResponse(vm)),
Some: vm => (IActionResult)new CreatedResult(
$"/api/playouts/{vm.PlayoutId}",
ToResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))),
None: () => ApiResults.NotFoundProblem());
});
});
@@ -194,7 +196,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
return result.Match(
Left: error => error.ToErrorResult(),
Right: playout => (IActionResult)new OkObjectResult(ToResponse(playout)));
Right: playout => (IActionResult)new OkObjectResult(
ToResponse(playout, entityLocker.IsPlayoutLocked(id))));
}
private async Task<Either<BaseError, PlayoutNameViewModel>> UpdateScheduleFile(
@@ -260,7 +263,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
Option<PlayoutNameViewModel> refreshed = await mediator.Send(new GetPlayoutById(id), cancellationToken);
return refreshed.Match(
Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm)),
Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm, entityLocker.IsPlayoutLocked(id))),
None: () => ApiResults.NotFoundProblem());
}
@@ -550,14 +553,19 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
[Tags("Playouts")]
[EndpointSummary("Reset all playouts")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ResetAllPlayoutsResponseModel), StatusCodes.Status202Accepted)]
// No 409 lock guard here (unlike the id-keyed mutations): ResetAllPlayoutsHandler already
// skips any playout whose build lock is held, matching Blazor. It is a fire-and-forget
// bulk enqueue, so it always accepts. See docs/decisions.md 2026-07-10.
// bulk enqueue, so it always accepts — the 202 body reports which playouts were queued and
// which were skipped (locked, or an unsupported ExternalJson/None kind). See docs/decisions.md 2026-07-10.
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
{
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
return Accepted();
ResetAllPlayoutsResult result = await mediator.Send(new ResetAllPlayouts(), cancellationToken);
var body = new ResetAllPlayoutsResponseModel(
result.QueuedPlayoutIds,
result.SkippedLocked,
result.SkippedUnsupported);
return new AcceptedResult((string)null, body);
}
[HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")]
@@ -728,7 +736,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
private static PlayoutHistoryDetailsResponseModel ToDetailsResponse(PlayoutHistoryDetailsViewModel vm) =>
new(vm.PlaybackOrder, vm.CollectionType, vm.Name, vm.MediaItemType, vm.MediaItemTitle);
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked) =>
PlayoutResponseModel.From(
vm.PlayoutId,
vm.ScheduleKind,
@@ -740,7 +748,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus),
vm.DecoId,
vm.DecoName);
vm.DecoName,
isLocked);
private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) =>
new(
+13 -4
View File
@@ -112,12 +112,21 @@ Established by issue #215 (`PlayoutController` + `ChannelController.ResetPlayout
`[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]` to each guarded
action. Precedent for the 409 shape: `TraktController` (its private `ConflictProblem()`). Two nuances:
- **Fire-and-forget bulk operations don't 409** — `POST /api/playouts/reset-all` stays 202; its
handler (`ResetAllPlayoutsHandler`) already *skips* locked playouts, matching Blazor + the handler
semantics. Only per-id mutations 409.
handler (`ResetAllPlayoutsHandler`) *skips* locked playouts, matching Blazor + the handler
semantics. Only per-id mutations 409. As of #235 the handler returns a `ResetAllPlayoutsResult`
(`QueuedPlayoutIds` / `SkippedLocked` / `SkippedUnsupported`) and the controller returns the 202
**with a `ResetAllPlayoutsResponseModel` body** reporting what was queued vs. skipped (locked, or
an unsupported `ExternalJson`/`None` kind) — a fire-and-forget bulk op still reports its outcome
rather than silently swallowing skips.
- **Surface the lock state to clients** so they can pre-disable the buttons: stamp an `IsLocked`
boolean onto the list DTO (`PlayoutListItemResponseModel`, set from `IsPlayoutLocked` in the
controller's list projection) rather than adding a push channel. The SPA reads it and, on a 409,
refreshes the list to pick up the flag.
controller's list projection) rather than adding a push channel — and (as of #235) onto the
single-playout GET DTO (`PlayoutResponseModel.IsLocked`, set the same way in every action that
maps it) so a client polling one playout has the same flag. The SPA reads it and, on a 409,
refreshes to pick up the flag.
- **Async-op success is 202, not 200** — an endpoint whose success path only *queues* a background
rebuild returns **202 Accepted**, not 200 (#235: `POST /api/channels/{channelNumber}/playout/reset`
queues a `BuildPlayout``AcceptedResult`). Reserve 200 for a synchronous durable result.
### 3b. Map a "queue a background job" outcome to status codes with an enum, not a `bool`