Merge remote-tracking branch 'origin/fix/215-entitylocker' into integrate/review-gates

# Conflicts:
#	docs/blazor-route-parity.md
#	docs/decisions.md
This commit is contained in:
2026-07-11 01:49:16 +02:00
15 changed files with 482 additions and 15 deletions
@@ -11,4 +11,5 @@ public record PlayoutListItemResponseModel(
string ScheduleName,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus,
ChannelPlayoutMode PlayoutMode);
ChannelPlayoutMode PlayoutMode,
bool IsLocked);
@@ -26,6 +26,7 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status409Conflict)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)]
@@ -113,22 +114,33 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status409Conflict)]
[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.Status409Conflict)]
[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.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), 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)]
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using static LanguageExt.Prelude;
@@ -27,6 +28,7 @@ public class ChannelControllerTests
{
private IMediator _mediator = null!;
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
private IEntityLocker _entityLocker = null!;
private ChannelController _controller = null!;
[SetUp]
@@ -34,7 +36,8 @@ public class ChannelControllerTests
{
_mediator = Substitute.For<IMediator>();
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
_controller = new ChannelController(_workerChannel.Writer, _mediator);
_entityLocker = Substitute.For<IEntityLocker>();
_controller = new ChannelController(_workerChannel.Writer, _mediator, _entityLocker);
}
[Test]
@@ -374,6 +377,20 @@ public class ChannelControllerTests
buildPlayout.Mode.ShouldBe(expectedMode);
}
[Test]
public async Task ResetPlayout_Should_Return_409_When_Playout_Locked()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
_workerChannel.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
{
@@ -115,6 +115,7 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/bulk/delete", "post", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "409")]
[TestCase("/api/channel-templates/default", "get", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
@@ -188,12 +189,21 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/playouts/{id}", "get", "404")]
[TestCase("/api/playouts", "post", "404")]
[TestCase("/api/playouts", "post", "422")]
[TestCase("/api/playouts/{id}", "put", "404")]
[TestCase("/api/playouts/{id}", "put", "409")]
[TestCase("/api/playouts/{id}", "put", "422")]
[TestCase("/api/playouts/{id}", "delete", "404")]
[TestCase("/api/playouts/{id}", "delete", "409")]
[TestCase("/api/playouts/{id}", "delete", "422")]
[TestCase("/api/playouts/{id}/deco", "put", "409")]
[TestCase("/api/playouts/{id}/alternate-schedules", "put", "409")]
[TestCase("/api/playouts/{id}/templates", "put", "409")]
[TestCase("/api/playouts/{id}/items", "get", "404")]
[TestCase("/api/playouts/{id}/erase-items", "post", "404")]
[TestCase("/api/playouts/{id}/erase-items", "post", "409")]
[TestCase("/api/playouts/{id}/erase-items", "post", "422")]
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "404")]
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "409")]
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "422")]
[TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")]
[TestCase("/api/collections/{id}/custom-order", "put", "404")]
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using MediatR;
@@ -31,12 +32,14 @@ public class PlayoutControllerTests
{
private PlayoutController _controller = null!;
private IMediator _mediator = null!;
private IEntityLocker _entityLocker = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new PlayoutController(_mediator);
_entityLocker = Substitute.For<IEntityLocker>();
_controller = new PlayoutController(_mediator, _entityLocker);
}
[Test]
@@ -68,6 +71,82 @@ public class PlayoutControllerTests
"/api/playouts/items/{id:int}/scheduling-context");
}
// ----- Build-lock guard (#215): id-keyed mutations return 409 while the build lock is held -----
[Test]
public async Task Delete_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.Delete(9, CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItems_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItemsAndHistory_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.Update(
9,
new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null),
CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateDefaultDeco_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.UpdateDefaultDeco(
9,
new UpdateDefaultDecoRequest(null),
CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
}
[Test]
public async Task GetAll_Should_Stamp_IsLocked_From_Locker()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutsViewModel(1, [MakePlayout(9)]));
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
result.Page.Single().IsLocked.ShouldBeTrue();
}
// ----- Erase items / history -----
[Test]
+14 -1
View File
@@ -7,6 +7,7 @@ using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using MediatR;
@@ -16,7 +17,10 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
public class ChannelController(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IMediator mediator,
IEntityLocker entityLocker)
{
[HttpGet("/api/channels")]
[EndpointGroupName("general")]
@@ -192,6 +196,7 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResetPlayout(
string channelNumber,
[FromQuery] PlayoutBuildMode? mode,
@@ -201,6 +206,14 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
foreach (int playoutId in maybePlayoutId)
{
// Mirror Blazor's EntityLocker gating: don't enqueue a rebuild while one is already in flight.
if (entityLocker.IsPlayoutLocked(playoutId))
{
return ApiResults.ConflictProblem(
"Playout build in progress",
"A build for this playout is currently in progress; try again once it completes.");
}
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
return new OkResult();
+62 -4
View File
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Api.Playouts;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -18,10 +19,21 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class PlayoutController(IMediator mediator) : ControllerBase
public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : ControllerBase
{
private const int MaxPageSize = 100;
// Blazor disables per-playout Reset/Erase/Delete/Edit while a build is in flight
// (EntityLocker.IsPlayoutLocked); the API mirrors that invariant by rejecting any
// id-keyed mutation with 409 while the build lock is held. See docs/decisions.md 2026-07-10.
private const string BuildInProgressTitle = "Playout build in progress";
private const string BuildInProgressDetail =
"A build for this playout is currently in progress; try again once it completes.";
private static IActionResult PlayoutLockedProblem() =>
ApiResults.ConflictProblem(BuildInProgressTitle, BuildInProgressDetail);
[HttpGet("/api/playouts", Name = "GetPlayouts")]
[Tags("Playouts")]
[EndpointSummary("List playouts")]
@@ -37,7 +49,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
return new PagedPlayoutsResponseModel(
result.TotalCount,
result.Page.Map(ToListItemResponse).ToList());
result.Page.Map(vm => ToListItemResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))).ToList());
}
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
@@ -131,12 +143,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdatePlayoutDetailsRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -204,12 +222,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateDefaultDeco(
int id,
[Required] [FromBody] UpdateDefaultDecoRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -284,12 +308,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceAlternateSchedules(
int id,
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -376,12 +406,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceTemplates(
int id,
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -515,6 +551,9 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointSummary("Reset all playouts")]
[EndpointGroupName("general")]
[ProducesResponseType(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.
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
{
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
@@ -531,9 +570,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItems(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -563,9 +608,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItemsAndHistory(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -608,9 +659,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Either<BaseError, Unit> result = await mediator.Send(new DeletePlayout(id), cancellationToken);
return result.ToDeletedResult();
}
@@ -722,7 +779,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.EndDay,
vm.EndYear);
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm, bool isLocked) =>
new(
vm.PlayoutId,
vm.ChannelNumber,
@@ -731,7 +788,8 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.ScheduleName,
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus),
vm.PlayoutMode);
vm.PlayoutMode,
isLocked);
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null
+7
View File
@@ -50,6 +50,13 @@ public static class ApiResults
public static IActionResult NotFoundProblem(string detail = "Resource not found") =>
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", detail));
/// <summary>
/// 409 <see cref="ProblemDetails" /> directly — for a mutation that races a background operation
/// holding a lock (e.g. a playout build in flight). Mirrors <see cref="NotFoundProblem" />.
/// </summary>
public static IActionResult ConflictProblem(string title, string detail) =>
new ConflictObjectResult(CreateProblemDetails(409, title, detail));
private static ProblemDetails CreateProblemDetails(int status, string title, string detail) =>
new()
{
+165 -1
View File
@@ -1764,6 +1764,26 @@
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
@@ -7399,6 +7419,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -7461,6 +7501,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -7656,6 +7716,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -7862,6 +7942,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -8068,6 +8168,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -8393,6 +8513,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -8459,6 +8599,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -19714,7 +19874,8 @@
"scheduleName",
"dailyRebuildTime",
"buildStatus",
"playoutMode"
"playoutMode",
"isLocked"
],
"type": "object",
"properties": {
@@ -19753,6 +19914,9 @@
},
"playoutMode": {
"$ref": "#/components/schemas/ChannelPlayoutMode"
},
"isLocked": {
"type": "boolean"
}
}
},
+28
View File
@@ -67,6 +67,34 @@ hand-rolling `IActionResult` status codes:
| `ToDeletedResult()` | `Either<BaseError, Unit>` | `Left``ToErrorResult()`; `Right` → 204 |
| `ToGetResult()` | `Option<T>` | `Some` → 200 + body; `None` → 404 |
| `ApiResults.NotFoundProblem(detail?)` | — | 404 `ProblemDetails` directly (e.g. when a controller has to pre-check existence itself, see `TemplateController.DeleteGroup`) |
| `ApiResults.ConflictProblem(title, detail)` | — | 409 `ProblemDetails` directly — for a mutation that races a background operation holding a lock (see §3a) |
### 3a. 409 when a mutation races a background lock
When an endpoint mutates an entity that a background operation may be actively rebuilding under an
`IEntityLocker` lock, guard the mutation and return **409 Conflict** (`ApiResults.ConflictProblem`)
while the lock is held. This mirrors the Blazor UI, which disables the same actions while the lock
event is live.
**This guard is advisory check-then-act, not mutual exclusion.** It narrows the race but does not
eliminate it: a build already queued can acquire the lock a moment *after* the check passes, and the
mutation then interleaves with the build anyway. That residual window is accepted where the
consequences are self-healing (a playout half-mutated during a build is corrected by the next
rebuild). If an entity's consequences were NOT self-healing, this pattern would be insufficient —
the mutation would need to actually acquire the lock for its duration instead.
Established by issue #215 (`PlayoutController` + `ChannelController.ResetPlayout`): inject
`IEntityLocker`, and at the top of every id-keyed mutation (`PUT`/`POST`/`DELETE`) check
`IsPlayoutLocked(id)``ConflictProblem("Playout build in progress", ...)`; add
`[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.
- **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.
`NotFoundError : BaseError` lives in `ErsatzTV.Core/Errors/NotFoundError.cs` — return it from a
handler's validation when a lookup fails, so the controller-side mapping falls out for free.
+8 -1
View File
@@ -59,7 +59,7 @@ redirect).
> | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
> | Playout creation + alternate-schedules | PARITY-OK | — |
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09) | — |
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — |
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear added; 100/kind cap → decisions.md) | — |
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
> | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 |
@@ -207,6 +207,13 @@ navigation stays live. `SearchScreen.addAll` also binds its completion to the re
late `GET /api/search/all-items` can no longer open a bulk-add dialog scoped to the previous query.
See `docs/spa-conventions.md` §3a for the pattern.
CLOSED 2026-07-10: **#215** (adversarial-reviewer#18 removal gate) — Blazor's `EntityLocker`
build-lock gating of per-playout Reset/Erase/Delete/Edit is now enforced server-side: every
id-keyed `PlayoutController` mutation + `ChannelController.ResetPlayout` returns **409** while
`IsPlayoutLocked(id)`, and the SPA mirrors the lock via an `IsLocked` flag on the playout list
DTO (disables the buttons + shows a "Building…" cue). The safety invariant no longer depends on
Blazor, so its removal can't silently drop it. See `docs/api-conventions.md` §3a + `decisions.md`.
## Section 4 — Blazor home / escape hatch
Not gated on an issue — kept separate from Section 3 because it isn't blocked on anything, just the
+37
View File
@@ -226,6 +226,43 @@ TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s t
via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add
command exists server-side. Issues #208/#209.
## 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight
(`EntityLocker.IsPlayoutLocked`, `Playouts.razor` + per-kind editors); the REST API had no
equivalent, so a client could race an in-flight build with a destructive `ExecuteDelete` and leave
a half-built playout. Adversarial-reviewer#18 promoted this to a #91-phase-(b) removal gate: after
Blazor is deleted the invariant would vanish entirely.
Decision: enforce the invariant **server-side** on the API rather than re-implementing a live push
channel. `PlayoutController` and `ChannelController` inject `IEntityLocker`; every id-keyed mutation
`PUT /api/playouts/{id}`, `.../deco`, `.../alternate-schedules`, `.../templates`,
`POST .../erase-items`, `.../erase-items-and-history`, `DELETE /api/playouts/{id}`, and
`POST /api/channels/{channelNumber}/playout/reset` — checks `IsPlayoutLocked(id)` first and returns
**409 Conflict** (`ApiResults.ConflictProblem`, new shared helper mirroring `NotFoundProblem`) while
the build lock is held. The PUTs are gated too (not just the destructive ops): the target invariant
is "no mutation during a build", matching Blazor's edit-disable.
- **The guard is advisory check-then-act, not mutual exclusion** — same posture as Blazor's disabled
buttons. A `BuildPlayout` already sitting in the worker queue can take the lock a few milliseconds
after the check passes, so the original race is *narrowed*, not eliminated; consequences remain
self-healing (the next rebuild corrects a half-mutated playout). True prevention — having each
mutation acquire the playout lock for its duration — was deliberately not taken: `LockPlayout`
publishes `PlayoutUpdatedNotification` (UI churn per mutation) and would make mutations block
builds, a semantics change out of scope for restoring Blazor parity.
- **`reset-all` is deliberately NOT gated** — it stays 202. `ResetAllPlayoutsHandler` already
*silently skips* locked playouts, which matches Blazor and the handler semantics; a fire-and-forget
bulk enqueue always accepts.
- **SPA mirrors the lock via data, not a push channel**`PlayoutListItemResponseModel` gains an
`IsLocked` bool (set from `IsPlayoutLocked` in the controller's list projection). The playouts
screen disables Reset/Erase/Erase-and-history/Delete for a locked row and shows a "Building…"
Badge; on a 409 from any mutation it surfaces the error and calls `query.refresh()` so the row
picks up the flag. No new polling was added (the existing 30s channel-state poll is unchanged).
Precedent for the 409 shape: `TraktController` (left as-is with its own private `ConflictProblem()`
to keep the diff small). Convention recorded in `api-conventions.md` §3a.
## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
With multiple sessions/agents working the repo in parallel, the old protocol — every session
+20
View File
@@ -2005,6 +2005,25 @@ describe('ChicoryTV SPA scaffold', () => {
expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
});
it('disables mutation buttons and shows a Building cue for a locked (building) playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
playouts: { page: [listPlayout({ id: 20, isLocked: true, scheduleKind: 'Block' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByText('Building…')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Erase items' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
});
it('disables Alternate schedules for an on-demand Classic playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
@@ -3531,6 +3550,7 @@ function listPlayout(overrides: Record<string, unknown> = {}): Record<string, un
channelNumber: '5.1',
dailyRebuildTime: '04:00:00',
id: 20,
isLocked: false,
scheduleKind: 'Classic',
scheduleName: 'Prime Time Cartoons',
...overrides
+18 -5
View File
@@ -134,6 +134,7 @@ import {
resetChannelPlayout,
deleteChannel,
getDecos,
ApiError,
messageFromError,
addScheduleItem,
deleteScheduleItem,
@@ -3092,6 +3093,11 @@ function PlayoutsScreen() {
})
.catch((error: unknown) => {
setMutationError(messageFromError(error));
// A 409 means a build lock is now held for this playout; refresh so the row
// picks up its IsLocked state and the mutation buttons disable themselves.
if (error instanceof ApiError && error.status === 409) {
query.refresh();
}
})
.finally(() => {
setMutatingState(false);
@@ -3193,6 +3199,9 @@ function PlayoutsScreen() {
}
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber);
// The playout's build lock is held (a build is in flight). Server rejects destructive
// mutations with 409 while locked; mirror that by disabling the buttons here.
const selectedLocked = selectedSummary.isLocked;
const nowPlaying = selectedState?.nowPlaying ?? null;
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
const nextItem = nextPlayoutItem(items, nowItem);
@@ -3257,7 +3266,7 @@ function PlayoutsScreen() {
<div className="ctv-playouts-title">
<ChannelLogo name={selectedSummary.channelName} size={38} />
<div>
<span><code>{selectedSummary.channelNumber}</code> {selectedState?.onAir && <Badge tone="accent" dot>On air</Badge>}</span>
<span><code>{selectedSummary.channelNumber}</code> {selectedState?.onAir && <Badge tone="accent" dot>On air</Badge>} {selectedLocked && <Badge tone="warn" dot>Building</Badge>}</span>
<h2>{selectedSummary.channelName}</h2>
</div>
</div>
@@ -3345,20 +3354,22 @@ function PlayoutsScreen() {
)}
<div className="ctv-playout-detail-actions">
<Button
disabled={mutating}
disabled={mutating || selectedLocked}
onClick={resetSelectedChannel}
size="sm"
startIcon={<RefreshCw aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="secondary"
>
Reset
</Button>
{selectedSummary.scheduleKind === 'Block' && (
<Button
disabled={mutating}
disabled={mutating || selectedLocked}
onClick={eraseSelectedItems}
size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="ghost"
>
Erase items
@@ -3369,20 +3380,22 @@ function PlayoutsScreen() {
selectedSummary.scheduleKind === 'Sequential' ||
selectedSummary.scheduleKind === 'Scripted') && (
<Button
disabled={mutating}
disabled={mutating || selectedLocked}
onClick={eraseSelectedItemsAndHistory}
size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="ghost"
>
Erase items and history
</Button>
)}
<Button
disabled={mutating}
disabled={mutating || selectedLocked}
onClick={deleteSelectedPlayout}
size="sm"
startIcon={<Trash2 aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="danger"
>
Delete
+1
View File
@@ -1077,6 +1077,7 @@ export interface components {
"dailyRebuildTime": null | string;
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
"isLocked": boolean;
};
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
"PlayoutResponseModel": {