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