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; namespace ErsatzTV.Application.Playouts;
public record ResetAllPlayouts : IRequest; public record ResetAllPlayouts : IRequest<ResetAllPlayoutsResult>;
@@ -11,33 +11,49 @@ public class ResetAllPlayoutsHandler(
IEntityLocker locker, IEntityLocker locker,
ChannelWriter<IBackgroundServiceRequest> channel, ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory) 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); 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)) foreach (Playout playout in await dbContext.Playouts.ToListAsync(cancellationToken))
{ {
switch (playout.ScheduleKind) switch (playout.ScheduleKind)
{ {
case PlayoutScheduleKind.Classic: case PlayoutScheduleKind.Classic:
if (!locker.IsPlayoutLocked(playout.Id)) if (locker.IsPlayoutLocked(playout.Id))
{
skippedLocked.Add(playout.Id);
}
else
{ {
await channel.WriteAsync( await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh),
cancellationToken); cancellationToken);
queued.Add(playout.Id);
} }
break; break;
case PlayoutScheduleKind.Block: case PlayoutScheduleKind.Block:
case PlayoutScheduleKind.Sequential: case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted: case PlayoutScheduleKind.Scripted:
if (!locker.IsPlayoutLocked(playout.Id)) if (locker.IsPlayoutLocked(playout.Id))
{
skippedLocked.Add(playout.Id);
}
else
{ {
await channel.WriteAsync( await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
cancellationToken); cancellationToken);
queued.Add(playout.Id);
} }
break; break;
@@ -45,8 +61,11 @@ public class ResetAllPlayoutsHandler(
case PlayoutScheduleKind.None: case PlayoutScheduleKind.None:
default: default:
// external json cannot be reset // external json cannot be reset
skippedUnsupported.Add(playout.Id);
continue; 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, TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus, PlayoutBuildStatusResponseModel? BuildStatus,
int? DecoId, int? DecoId,
string? DecoName) string? DecoName,
bool IsLocked)
{ {
public static PlayoutResponseModel From( public static PlayoutResponseModel From(
int id, int id,
@@ -27,7 +28,8 @@ public record PlayoutResponseModel(
TimeSpan? dailyRebuildTime, TimeSpan? dailyRebuildTime,
PlayoutBuildStatusResponseModel? buildStatus, PlayoutBuildStatusResponseModel? buildStatus,
int? decoId, int? decoId,
string? decoName) => string? decoName,
bool isLocked) =>
new( new(
id, id,
scheduleKind, scheduleKind,
@@ -39,5 +41,6 @@ public record PlayoutResponseModel(
dailyRebuildTime, dailyRebuildTime,
buildStatus, buildStatus,
decoId, 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); 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(); _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
var buildPlayout = request.ShouldBeOfType<BuildPlayout>(); var buildPlayout = request.ShouldBeOfType<BuildPlayout>();
buildPlayout.PlayoutId.ShouldBe(9); buildPlayout.PlayoutId.ShouldBe(9);
@@ -436,7 +436,7 @@ public class ChannelControllerTests
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None); 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(); _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
request.ShouldBeOfType<BuildPlayout>().Mode.ShouldBe(PlayoutBuildMode.Continue); request.ShouldBeOfType<BuildPlayout>().Mode.ShouldBe(PlayoutBuildMode.Continue);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>()); await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
@@ -527,6 +527,21 @@ public class PlayoutControllerTests
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(ToResponse(vm)); 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] [Test]
public async Task GetById_Should_Return_404_For_None_With_ProblemDetails() public async Task GetById_Should_Return_404_For_None_With_ProblemDetails()
{ {
@@ -693,9 +708,17 @@ public class PlayoutControllerTests
[Test] [Test]
public async Task ResetAll_Should_Return_202_And_Send_Command() 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); 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>()); await _mediator.Received(1).Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>());
} }
@@ -1286,7 +1309,7 @@ public class PlayoutControllerTests
null, null,
null); null);
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) => private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) =>
PlayoutResponseModel.From( PlayoutResponseModel.From(
vm.PlayoutId, vm.PlayoutId,
vm.ScheduleKind, vm.ScheduleKind,
@@ -1303,7 +1326,8 @@ public class PlayoutControllerTests
vm.BuildStatus.Success, vm.BuildStatus.Success,
vm.BuildStatus.Message), vm.BuildStatus.Message),
vm.DecoId, vm.DecoId,
vm.DecoName); vm.DecoName,
isLocked);
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) 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. " + "progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " +
"Pass mode to force a specific PlayoutBuildMode.")] "Pass mode to force a specific PlayoutBuildMode.")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResetPlayout( public async Task<IActionResult> ResetPlayout(
@@ -233,7 +233,7 @@ public class ChannelController(
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken); PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken); await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
return new OkResult(); return new AcceptedResult();
} }
return ApiResults.NotFoundProblem(); 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) public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{ {
Option<PlayoutNameViewModel> result = await mediator.Send(new GetPlayoutById(id), 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")] [HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
@@ -128,7 +128,9 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
Option<PlayoutNameViewModel> playout = Option<PlayoutNameViewModel> playout =
await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken); await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken);
return playout.Match( 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()); None: () => ApiResults.NotFoundProblem());
}); });
}); });
@@ -194,7 +196,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
return result.Match( return result.Match(
Left: error => error.ToErrorResult(), 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( 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); Option<PlayoutNameViewModel> refreshed = await mediator.Send(new GetPlayoutById(id), cancellationToken);
return refreshed.Match( return refreshed.Match(
Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm)), Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm, entityLocker.IsPlayoutLocked(id))),
None: () => ApiResults.NotFoundProblem()); None: () => ApiResults.NotFoundProblem());
} }
@@ -550,14 +553,19 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
[Tags("Playouts")] [Tags("Playouts")]
[EndpointSummary("Reset all playouts")] [EndpointSummary("Reset all playouts")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(typeof(ResetAllPlayoutsResponseModel), StatusCodes.Status202Accepted)]
// No 409 lock guard here (unlike the id-keyed mutations): ResetAllPlayoutsHandler already // 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 // 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) public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
{ {
await mediator.Send(new ResetAllPlayouts(), cancellationToken); ResetAllPlayoutsResult result = await mediator.Send(new ResetAllPlayouts(), cancellationToken);
return Accepted(); 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")] [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) => private static PlayoutHistoryDetailsResponseModel ToDetailsResponse(PlayoutHistoryDetailsViewModel vm) =>
new(vm.PlaybackOrder, vm.CollectionType, vm.Name, vm.MediaItemType, vm.MediaItemTitle); 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( PlayoutResponseModel.From(
vm.PlayoutId, vm.PlayoutId,
vm.ScheduleKind, vm.ScheduleKind,
@@ -740,7 +748,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
vm.DbDailyRebuildTime, vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus), ToBuildStatus(vm.BuildStatus),
vm.DecoId, vm.DecoId,
vm.DecoName); vm.DecoName,
isLocked);
private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) => private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) =>
new( new(
+13 -4
View File
@@ -112,12 +112,21 @@ Established by issue #215 (`PlayoutController` + `ChannelController.ResetPlayout
`[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]` to each guarded `[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]` to each guarded
action. Precedent for the 409 shape: `TraktController` (its private `ConflictProblem()`). Two nuances: 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 - **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 handler (`ResetAllPlayoutsHandler`) *skips* locked playouts, matching Blazor + the handler
semantics. Only per-id mutations 409. 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` - **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 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, controller's list projection) rather than adding a push channel — and (as of #235) onto the
refreshes the list to pick up the flag. 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` ### 3b. Map a "queue a background job" outcome to status codes with an enum, not a `bool`