feat(api): playout erase/scheduling-context + collection custom-order (#210, #211)

Backend slice for the ChicoryTV playouts and collections screens.

PlayoutController:
- POST /api/playouts/{id}/erase-items (204; 404 pre-check; 422 unless
  Block/Sequential/Scripted) -> ErasePlayoutItems
- POST /api/playouts/{id}/erase-items-and-history (204; 404; 422 unless
  Classic/Block/Sequential/Scripted) -> ErasePlayoutHistory
- GET /api/playouts/items/{id}/scheduling-context (200/404) decodes a
  playout item's stored context by row id via a new
  GetPlayoutItemSchedulingContext query that reuses ProcessSchedulingContext
- PlayoutItemResponseModel gains HasSchedulingContext (no raw JSON in list)
- PlayoutListItemResponseModel gains PlayoutMode (ChannelNumber already present)

CollectionController:
- PUT /api/collections/{id}/custom-order (204; 404 pre-check; 422) with
  UpdateCollectionCustomOrderRequest deriving CustomIndex from array order
- GetCollectionItemsHandler orders by CustomIndex (nulls last) then title/id
  when the collection's UseCustomPlaybackOrder is set

Tests: controller route + behavior tests, OpenAPI ProblemDetails TestCases,
GetCollectionItems custom-order handler test. Regenerated v1.json, v1.d.ts,
endpoint-index.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 22:05:51 +02:00
co-authored by Claude Fable 5
parent eb6e712c38
commit f1b3879d51
16 changed files with 904 additions and 21 deletions
@@ -96,6 +96,33 @@ public class CollectionController(IMediator mediator) : ControllerBase
});
}
[HttpPut("/api/collections/{id:int}/custom-order")]
[Tags("Collections")]
[EndpointSummary("Set a collection's custom playback order")]
[EndpointDescription(
"Replaces the custom playback order of a manual collection. CustomIndex is assigned from array position " +
"(the request body has no index field); ids that are not members of the collection are ignored. Set " +
"UseCustomPlaybackOrder on the collection for this order to take effect.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateCustomOrder(
int id,
[Required] [FromBody] UpdateCollectionCustomOrderRequest request,
CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> maybeCollection =
await mediator.Send(new GetCollectionById(id), cancellationToken);
if (maybeCollection.IsNone)
{
return ApiResults.NotFoundProblem();
}
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpDelete("/api/collections/{id:int}")]
[Tags("Collections")]
[EndpointSummary("Delete a collection")]
+85 -2
View File
@@ -521,6 +521,87 @@ public class PlayoutController(IMediator mediator) : ControllerBase
return Accepted();
}
[HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")]
[Tags("Playouts")]
[EndpointSummary("Erase a playout's items")]
[EndpointDescription(
"Deletes the built items (plus gaps and build status) for a Block, Sequential, or Scripted playout, " +
"preserving history that precedes the currently-airing item. Only valid for those kinds; other kinds " +
"return 422.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItems(int id, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
foreach (PlayoutNameViewModel playout in maybePlayout)
{
if (playout.ScheduleKind is not (PlayoutScheduleKind.Block or PlayoutScheduleKind.Sequential
or PlayoutScheduleKind.Scripted))
{
return BaseError.New("[EraseItems] is only valid for Block, Sequential, or Scripted playouts")
.ToErrorResult();
}
}
await mediator.Send(new ErasePlayoutItems(id), cancellationToken);
return NoContent();
}
[HttpPost("/api/playouts/{id:int}/erase-items-and-history", Name = "ErasePlayoutItemsAndHistory")]
[Tags("Playouts")]
[EndpointSummary("Erase a playout's items and history")]
[EndpointDescription(
"Deletes all built items, history, anchors, and build status for a Classic, Block, Sequential, or " +
"Scripted playout, and reseeds it. Only valid for those kinds; other kinds return 422.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItemsAndHistory(int id, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
foreach (PlayoutNameViewModel playout in maybePlayout)
{
if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
{
return BaseError.New(
"[EraseItemsAndHistory] is only valid for Classic, Block, Sequential, or Scripted playouts")
.ToErrorResult();
}
}
await mediator.Send(new ErasePlayoutHistory(id), cancellationToken);
return NoContent();
}
[HttpGet("/api/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
[Tags("Playouts")]
[EndpointSummary("Decode a playout item's scheduling context")]
[EndpointDescription(
"Decodes the stored scheduling context for a single playout item (by its row id) into readable, enriched " +
"JSON. Returns 404 when the item is missing or has no scheduling context.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutItemSchedulingContextResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItemSchedulingContext(int id, CancellationToken cancellationToken)
{
Option<string> result = await mediator.Send(new GetPlayoutItemSchedulingContext(id), cancellationToken);
return result.Map(context => new PlayoutItemSchedulingContextResponseModel(context)).ToGetResult();
}
[HttpDelete("/api/playouts/{id:int}")]
[Tags("Playouts")]
[EndpointSummary("Delete a playout")]
@@ -649,7 +730,8 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.ScheduleKind,
vm.ScheduleName,
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus));
ToBuildStatus(vm.BuildStatus),
vm.PlayoutMode);
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null
@@ -665,5 +747,6 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.Start,
vm.Finish,
vm.Duration,
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null));
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null),
!string.IsNullOrWhiteSpace(vm.SchedulingContext));
}
@@ -0,0 +1,13 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateCollectionCustomOrderRequest(List<int> MediaItemIds)
{
public UpdateCollectionCustomOrder ToCommand(int collectionId) =>
new(
collectionId,
(MediaItemIds ?? [])
.Select((mediaItemId, index) => new MediaItemCustomOrder(mediaItemId, index))
.ToList());
}