fix(api): gate playout mutations on EntityLocker build lock (409) + mirror lock state in SPA (fixes #215)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Blazor disabled per-playout Reset/Erase/Delete/Edit while a BuildPlayout was
in flight (EntityLocker.IsPlayoutLocked); 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. After Blazor removal this safety invariant would vanish
entirely (adversarial-reviewer#18 removal gate).
Server:
- Add public ApiResults.ConflictProblem(title, detail) (409, mirrors NotFoundProblem).
- Inject IEntityLocker into PlayoutController; guard every id-keyed mutation
(PUT {id}, PUT .../deco, PUT .../alternate-schedules, PUT .../templates,
POST .../erase-items, POST .../erase-items-and-history, DELETE {id}) → 409
when IsPlayoutLocked(id); add [ProducesResponseType(...409)] to each.
- Guard ChannelController.ResetPlayout the same way after resolving the id.
- reset-all stays 202 (ResetAllPlayoutsHandler already skips locked playouts).
- Stamp IsLocked onto PlayoutListItemResponseModel from IsPlayoutLocked.
SPA:
- Disable Reset/Erase/Erase-and-history/Delete for a locked row + show a
"Building…" Badge; on a 409 surface the error and refresh the list.
Tests: controller-level 409 guard tests (delete/erase/PUT/deco/channel-reset)
+ IsLocked projection test; new OpenAPI contract + metadata 409 rows.
Docs: api-conventions §3a, blazor-route-parity playouts verdict, decisions.md.
Regenerated v1.json + web types.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,4 +11,5 @@ public record PlayoutListItemResponseModel(
|
|||||||
string ScheduleName,
|
string ScheduleName,
|
||||||
TimeSpan? DailyRebuildTime,
|
TimeSpan? DailyRebuildTime,
|
||||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
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.Status404NotFound)]
|
||||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
|
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
|
[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.GetDefault), StatusCodes.Status404NotFound)]
|
||||||
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
|
||||||
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)]
|
[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.Status404NotFound)]
|
||||||
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)]
|
[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.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)]
|
[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.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
|
[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.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)]
|
[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.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
|
||||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)]
|
[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.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.GetById), StatusCodes.Status404NotFound)]
|
||||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
|
||||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)]
|
[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.Api.LibraryBrowse;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Errors;
|
using ErsatzTV.Core.Errors;
|
||||||
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using static LanguageExt.Prelude;
|
using static LanguageExt.Prelude;
|
||||||
@@ -27,6 +28,7 @@ public class ChannelControllerTests
|
|||||||
{
|
{
|
||||||
private IMediator _mediator = null!;
|
private IMediator _mediator = null!;
|
||||||
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
|
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
|
||||||
|
private IEntityLocker _entityLocker = null!;
|
||||||
private ChannelController _controller = null!;
|
private ChannelController _controller = null!;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
@@ -34,7 +36,8 @@ public class ChannelControllerTests
|
|||||||
{
|
{
|
||||||
_mediator = Substitute.For<IMediator>();
|
_mediator = Substitute.For<IMediator>();
|
||||||
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
_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]
|
[Test]
|
||||||
@@ -374,6 +377,20 @@ public class ChannelControllerTests
|
|||||||
buildPlayout.Mode.ShouldBe(expectedMode);
|
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]
|
[Test]
|
||||||
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
|
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", "404")]
|
||||||
[TestCase("/api/channels/bulk/delete", "post", "422")]
|
[TestCase("/api/channels/bulk/delete", "post", "422")]
|
||||||
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
|
[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", "get", "404")]
|
||||||
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
|
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
|
||||||
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
|
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
|
||||||
@@ -188,12 +189,21 @@ public class OpenApiErrorResponseContractTests
|
|||||||
[TestCase("/api/playouts/{id}", "get", "404")]
|
[TestCase("/api/playouts/{id}", "get", "404")]
|
||||||
[TestCase("/api/playouts", "post", "404")]
|
[TestCase("/api/playouts", "post", "404")]
|
||||||
[TestCase("/api/playouts", "post", "422")]
|
[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", "404")]
|
||||||
|
[TestCase("/api/playouts/{id}", "delete", "409")]
|
||||||
[TestCase("/api/playouts/{id}", "delete", "422")]
|
[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}/items", "get", "404")]
|
||||||
[TestCase("/api/playouts/{id}/erase-items", "post", "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", "post", "422")]
|
||||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "404")]
|
[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/{id}/erase-items-and-history", "post", "422")]
|
||||||
[TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")]
|
[TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")]
|
||||||
[TestCase("/api/collections/{id}/custom-order", "put", "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.Filler;
|
||||||
using ErsatzTV.Core.Domain.Scheduling;
|
using ErsatzTV.Core.Domain.Scheduling;
|
||||||
using ErsatzTV.Core.Errors;
|
using ErsatzTV.Core.Errors;
|
||||||
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -31,12 +32,14 @@ public class PlayoutControllerTests
|
|||||||
{
|
{
|
||||||
private PlayoutController _controller = null!;
|
private PlayoutController _controller = null!;
|
||||||
private IMediator _mediator = null!;
|
private IMediator _mediator = null!;
|
||||||
|
private IEntityLocker _entityLocker = null!;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void SetUp()
|
public void SetUp()
|
||||||
{
|
{
|
||||||
_mediator = Substitute.For<IMediator>();
|
_mediator = Substitute.For<IMediator>();
|
||||||
_controller = new PlayoutController(_mediator);
|
_entityLocker = Substitute.For<IEntityLocker>();
|
||||||
|
_controller = new PlayoutController(_mediator, _entityLocker);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -68,6 +71,82 @@ public class PlayoutControllerTests
|
|||||||
"/api/playouts/items/{id:int}/scheduling-context");
|
"/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 -----
|
// ----- Erase items / history -----
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using ErsatzTV.Controllers.Api.Requests;
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Api.Channels;
|
using ErsatzTV.Core.Api.Channels;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
using ErsatzTV.Extensions;
|
using ErsatzTV.Extensions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -16,7 +17,10 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace ErsatzTV.Controllers.Api;
|
namespace ErsatzTV.Controllers.Api;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
|
public class ChannelController(
|
||||||
|
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||||
|
IMediator mediator,
|
||||||
|
IEntityLocker entityLocker)
|
||||||
{
|
{
|
||||||
[HttpGet("/api/channels")]
|
[HttpGet("/api/channels")]
|
||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
@@ -192,6 +196,7 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
|||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||||
public async Task<IActionResult> ResetPlayout(
|
public async Task<IActionResult> ResetPlayout(
|
||||||
string channelNumber,
|
string channelNumber,
|
||||||
[FromQuery] PlayoutBuildMode? mode,
|
[FromQuery] PlayoutBuildMode? mode,
|
||||||
@@ -201,6 +206,14 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
|||||||
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
|
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
|
||||||
foreach (int playoutId in maybePlayoutId)
|
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);
|
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 OkResult();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Api.Playouts;
|
|||||||
using ErsatzTV.Core.Api.Scheduling;
|
using ErsatzTV.Core.Api.Scheduling;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Domain.Filler;
|
using ErsatzTV.Core.Domain.Filler;
|
||||||
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
using ErsatzTV.Extensions;
|
using ErsatzTV.Extensions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -18,10 +19,21 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace ErsatzTV.Controllers.Api;
|
namespace ErsatzTV.Controllers.Api;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class PlayoutController(IMediator mediator) : ControllerBase
|
public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : ControllerBase
|
||||||
{
|
{
|
||||||
private const int MaxPageSize = 100;
|
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")]
|
[HttpGet("/api/playouts", Name = "GetPlayouts")]
|
||||||
[Tags("Playouts")]
|
[Tags("Playouts")]
|
||||||
[EndpointSummary("List playouts")]
|
[EndpointSummary("List playouts")]
|
||||||
@@ -37,7 +49,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
|
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
|
||||||
return new PagedPlayoutsResponseModel(
|
return new PagedPlayoutsResponseModel(
|
||||||
result.TotalCount,
|
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")]
|
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
|
||||||
@@ -131,12 +143,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<IActionResult> Update(
|
public async Task<IActionResult> Update(
|
||||||
int id,
|
int id,
|
||||||
[Required] [FromBody] UpdatePlayoutDetailsRequest request,
|
[Required] [FromBody] UpdatePlayoutDetailsRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (entityLocker.IsPlayoutLocked(id))
|
||||||
|
{
|
||||||
|
return PlayoutLockedProblem();
|
||||||
|
}
|
||||||
|
|
||||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||||
if (maybePlayout.IsNone)
|
if (maybePlayout.IsNone)
|
||||||
{
|
{
|
||||||
@@ -204,12 +222,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<IActionResult> UpdateDefaultDeco(
|
public async Task<IActionResult> UpdateDefaultDeco(
|
||||||
int id,
|
int id,
|
||||||
[Required] [FromBody] UpdateDefaultDecoRequest request,
|
[Required] [FromBody] UpdateDefaultDecoRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (entityLocker.IsPlayoutLocked(id))
|
||||||
|
{
|
||||||
|
return PlayoutLockedProblem();
|
||||||
|
}
|
||||||
|
|
||||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||||
if (maybePlayout.IsNone)
|
if (maybePlayout.IsNone)
|
||||||
{
|
{
|
||||||
@@ -284,12 +308,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<IActionResult> ReplaceAlternateSchedules(
|
public async Task<IActionResult> ReplaceAlternateSchedules(
|
||||||
int id,
|
int id,
|
||||||
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
|
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (entityLocker.IsPlayoutLocked(id))
|
||||||
|
{
|
||||||
|
return PlayoutLockedProblem();
|
||||||
|
}
|
||||||
|
|
||||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||||
if (maybePlayout.IsNone)
|
if (maybePlayout.IsNone)
|
||||||
{
|
{
|
||||||
@@ -376,12 +406,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<IActionResult> ReplaceTemplates(
|
public async Task<IActionResult> ReplaceTemplates(
|
||||||
int id,
|
int id,
|
||||||
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
|
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (entityLocker.IsPlayoutLocked(id))
|
||||||
|
{
|
||||||
|
return PlayoutLockedProblem();
|
||||||
|
}
|
||||||
|
|
||||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||||
if (maybePlayout.IsNone)
|
if (maybePlayout.IsNone)
|
||||||
{
|
{
|
||||||
@@ -515,6 +551,9 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
[EndpointSummary("Reset all playouts")]
|
[EndpointSummary("Reset all playouts")]
|
||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
[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)
|
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||||
@@ -531,9 +570,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<IActionResult> EraseItems(int id, CancellationToken cancellationToken)
|
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);
|
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||||
if (maybePlayout.IsNone)
|
if (maybePlayout.IsNone)
|
||||||
{
|
{
|
||||||
@@ -563,9 +608,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<IActionResult> EraseItemsAndHistory(int id, CancellationToken cancellationToken)
|
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);
|
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||||
if (maybePlayout.IsNone)
|
if (maybePlayout.IsNone)
|
||||||
{
|
{
|
||||||
@@ -608,9 +659,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
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);
|
Either<BaseError, Unit> result = await mediator.Send(new DeletePlayout(id), cancellationToken);
|
||||||
return result.ToDeletedResult();
|
return result.ToDeletedResult();
|
||||||
}
|
}
|
||||||
@@ -722,7 +779,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
vm.EndDay,
|
vm.EndDay,
|
||||||
vm.EndYear);
|
vm.EndYear);
|
||||||
|
|
||||||
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
|
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm, bool isLocked) =>
|
||||||
new(
|
new(
|
||||||
vm.PlayoutId,
|
vm.PlayoutId,
|
||||||
vm.ChannelNumber,
|
vm.ChannelNumber,
|
||||||
@@ -731,7 +788,8 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
|||||||
vm.ScheduleName,
|
vm.ScheduleName,
|
||||||
vm.DbDailyRebuildTime,
|
vm.DbDailyRebuildTime,
|
||||||
ToBuildStatus(vm.BuildStatus),
|
ToBuildStatus(vm.BuildStatus),
|
||||||
vm.PlayoutMode);
|
vm.PlayoutMode,
|
||||||
|
isLocked);
|
||||||
|
|
||||||
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
|
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
|
||||||
buildStatus is null
|
buildStatus is null
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ public static class ApiResults
|
|||||||
public static IActionResult NotFoundProblem(string detail = "Resource not found") =>
|
public static IActionResult NotFoundProblem(string detail = "Resource not found") =>
|
||||||
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", detail));
|
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) =>
|
private static ProblemDetails CreateProblemDetails(int status, string title, string detail) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7359,6 +7379,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": {
|
"422": {
|
||||||
"description": "Unprocessable Entity",
|
"description": "Unprocessable Entity",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -7421,6 +7461,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": {
|
"422": {
|
||||||
"description": "Unprocessable Entity",
|
"description": "Unprocessable Entity",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -7616,6 +7676,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": {
|
"422": {
|
||||||
"description": "Unprocessable Entity",
|
"description": "Unprocessable Entity",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -7822,6 +7902,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": {
|
"422": {
|
||||||
"description": "Unprocessable Entity",
|
"description": "Unprocessable Entity",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -8028,6 +8128,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": {
|
"422": {
|
||||||
"description": "Unprocessable Entity",
|
"description": "Unprocessable Entity",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -8353,6 +8473,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": {
|
"422": {
|
||||||
"description": "Unprocessable Entity",
|
"description": "Unprocessable Entity",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -8419,6 +8559,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": {
|
"422": {
|
||||||
"description": "Unprocessable Entity",
|
"description": "Unprocessable Entity",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -19667,7 +19827,8 @@
|
|||||||
"scheduleName",
|
"scheduleName",
|
||||||
"dailyRebuildTime",
|
"dailyRebuildTime",
|
||||||
"buildStatus",
|
"buildStatus",
|
||||||
"playoutMode"
|
"playoutMode",
|
||||||
|
"isLocked"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -19706,6 +19867,9 @@
|
|||||||
},
|
},
|
||||||
"playoutMode": {
|
"playoutMode": {
|
||||||
"$ref": "#/components/schemas/ChannelPlayoutMode"
|
"$ref": "#/components/schemas/ChannelPlayoutMode"
|
||||||
|
},
|
||||||
|
"isLocked": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -67,6 +67,27 @@ hand-rolling `IActionResult` status codes:
|
|||||||
| `ToDeletedResult()` | `Either<BaseError, Unit>` | `Left` → `ToErrorResult()`; `Right` → 204 |
|
| `ToDeletedResult()` | `Either<BaseError, Unit>` | `Left` → `ToErrorResult()`; `Right` → 204 |
|
||||||
| `ToGetResult()` | `Option<T>` | `Some` → 200 + body; `None` → 404 |
|
| `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.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, rather than letting the write race the build. This mirrors the Blazor UI,
|
||||||
|
which disables the same actions while the lock event is live.
|
||||||
|
|
||||||
|
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
|
`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.
|
handler's validation when a lookup fails, so the controller-side mapping falls out for free.
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ redirect).
|
|||||||
> | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
|
> | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
|
||||||
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
|
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
|
||||||
> | Playout creation + alternate-schedules | PARITY-OK | — |
|
> | 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) | — |
|
> | 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) | — |
|
> | 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 |
|
> | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 |
|
||||||
@@ -192,6 +192,12 @@ query-wide Add All via `GET /api/search/all-items`, Save As Smart Collection) an
|
|||||||
pages + child grids, per-show Quick/Deep scan gated to Plex/Jellyfin/Emby, per-episode Media
|
pages + child grids, per-show Quick/Deep scan gated to Plex/Jellyfin/Emby, per-episode Media
|
||||||
Info + Troubleshoot entries; `POST /api/playlists/{id}/items` added). Known accepted deviations
|
Info + Troubleshoot entries; `POST /api/playlists/{id}/items` added). Known accepted deviations
|
||||||
(select-mode toggle, per-card target superset) recorded in `docs/decisions.md`.
|
(select-mode toggle, per-card target superset) recorded in `docs/decisions.md`.
|
||||||
|
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
|
## Section 4 — Blazor home / escape hatch
|
||||||
|
|
||||||
|
|||||||
@@ -225,3 +225,32 @@ TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s t
|
|||||||
"Add All" (query-wide) mirrors Blazor's two-step: materialize ids
|
"Add All" (query-wide) mirrors Blazor's two-step: materialize ids
|
||||||
via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add
|
via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add
|
||||||
command exists server-side. Issues #208/#209.
|
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 invariant is "no
|
||||||
|
mutation during a build", matching Blazor's edit-disable.
|
||||||
|
|
||||||
|
- **`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.
|
||||||
|
|||||||
@@ -2005,6 +2005,25 @@ describe('ChicoryTV SPA scaffold', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
|
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 () => {
|
it('disables Alternate schedules for an on-demand Classic playout', async () => {
|
||||||
mockDashboardApi({
|
mockDashboardApi({
|
||||||
playoutItems: [playoutItem()],
|
playoutItems: [playoutItem()],
|
||||||
@@ -3531,6 +3550,7 @@ function listPlayout(overrides: Record<string, unknown> = {}): Record<string, un
|
|||||||
channelNumber: '5.1',
|
channelNumber: '5.1',
|
||||||
dailyRebuildTime: '04:00:00',
|
dailyRebuildTime: '04:00:00',
|
||||||
id: 20,
|
id: 20,
|
||||||
|
isLocked: false,
|
||||||
scheduleKind: 'Classic',
|
scheduleKind: 'Classic',
|
||||||
scheduleName: 'Prime Time Cartoons',
|
scheduleName: 'Prime Time Cartoons',
|
||||||
...overrides
|
...overrides
|
||||||
|
|||||||
+18
-5
@@ -134,6 +134,7 @@ import {
|
|||||||
resetChannelPlayout,
|
resetChannelPlayout,
|
||||||
deleteChannel,
|
deleteChannel,
|
||||||
getDecos,
|
getDecos,
|
||||||
|
ApiError,
|
||||||
messageFromError,
|
messageFromError,
|
||||||
addScheduleItem,
|
addScheduleItem,
|
||||||
deleteScheduleItem,
|
deleteScheduleItem,
|
||||||
@@ -3093,6 +3094,11 @@ function PlayoutsScreen() {
|
|||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
setMutationError(messageFromError(error));
|
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(() => {
|
.finally(() => {
|
||||||
setMutatingState(false);
|
setMutatingState(false);
|
||||||
@@ -3194,6 +3200,9 @@ function PlayoutsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber);
|
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 nowPlaying = selectedState?.nowPlaying ?? null;
|
||||||
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
|
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
|
||||||
const nextItem = nextPlayoutItem(items, nowItem);
|
const nextItem = nextPlayoutItem(items, nowItem);
|
||||||
@@ -3258,7 +3267,7 @@ function PlayoutsScreen() {
|
|||||||
<div className="ctv-playouts-title">
|
<div className="ctv-playouts-title">
|
||||||
<ChannelLogo name={selectedSummary.channelName} size={38} />
|
<ChannelLogo name={selectedSummary.channelName} size={38} />
|
||||||
<div>
|
<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>
|
<h2>{selectedSummary.channelName}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3346,20 +3355,22 @@ function PlayoutsScreen() {
|
|||||||
)}
|
)}
|
||||||
<div className="ctv-playout-detail-actions">
|
<div className="ctv-playout-detail-actions">
|
||||||
<Button
|
<Button
|
||||||
disabled={mutating}
|
disabled={mutating || selectedLocked}
|
||||||
onClick={resetSelectedChannel}
|
onClick={resetSelectedChannel}
|
||||||
size="sm"
|
size="sm"
|
||||||
startIcon={<RefreshCw aria-hidden="true" size={13} />}
|
startIcon={<RefreshCw aria-hidden="true" size={13} />}
|
||||||
|
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
{selectedSummary.scheduleKind === 'Block' && (
|
{selectedSummary.scheduleKind === 'Block' && (
|
||||||
<Button
|
<Button
|
||||||
disabled={mutating}
|
disabled={mutating || selectedLocked}
|
||||||
onClick={eraseSelectedItems}
|
onClick={eraseSelectedItems}
|
||||||
size="sm"
|
size="sm"
|
||||||
startIcon={<Eraser aria-hidden="true" size={13} />}
|
startIcon={<Eraser aria-hidden="true" size={13} />}
|
||||||
|
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Erase items
|
Erase items
|
||||||
@@ -3370,20 +3381,22 @@ function PlayoutsScreen() {
|
|||||||
selectedSummary.scheduleKind === 'Sequential' ||
|
selectedSummary.scheduleKind === 'Sequential' ||
|
||||||
selectedSummary.scheduleKind === 'Scripted') && (
|
selectedSummary.scheduleKind === 'Scripted') && (
|
||||||
<Button
|
<Button
|
||||||
disabled={mutating}
|
disabled={mutating || selectedLocked}
|
||||||
onClick={eraseSelectedItemsAndHistory}
|
onClick={eraseSelectedItemsAndHistory}
|
||||||
size="sm"
|
size="sm"
|
||||||
startIcon={<Eraser aria-hidden="true" size={13} />}
|
startIcon={<Eraser aria-hidden="true" size={13} />}
|
||||||
|
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Erase items and history
|
Erase items and history
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
disabled={mutating}
|
disabled={mutating || selectedLocked}
|
||||||
onClick={deleteSelectedPlayout}
|
onClick={deleteSelectedPlayout}
|
||||||
size="sm"
|
size="sm"
|
||||||
startIcon={<Trash2 aria-hidden="true" size={13} />}
|
startIcon={<Trash2 aria-hidden="true" size={13} />}
|
||||||
|
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
Vendored
+1
@@ -1076,6 +1076,7 @@ export interface components {
|
|||||||
"dailyRebuildTime": null | string;
|
"dailyRebuildTime": null | string;
|
||||||
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
|
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
|
||||||
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
|
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
|
||||||
|
"isLocked": boolean;
|
||||||
};
|
};
|
||||||
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
|
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
|
||||||
"PlayoutResponseModel": {
|
"PlayoutResponseModel": {
|
||||||
|
|||||||
Reference in New Issue
Block a user