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());
}
+324 -2
View File
@@ -2793,6 +2793,96 @@
}
}
},
"/api/collections/{id}/custom-order": {
"put": {
"tags": [
"Collections"
],
"summary": "Set a collection's custom playback order",
"description": "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.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/collections/{id}/items/{mediaItemId}": {
"delete": {
"tags": [
@@ -8132,6 +8222,201 @@
}
}
},
"/api/playouts/{id}/erase-items": {
"post": {
"tags": [
"Playouts"
],
"summary": "Erase a playout's items",
"description": "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.",
"operationId": "ErasePlayoutItems",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/playouts/{id}/erase-items-and-history": {
"post": {
"tags": [
"Playouts"
],
"summary": "Erase a playout's items and history",
"description": "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.",
"operationId": "ErasePlayoutItemsAndHistory",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/playouts/items/{id}/scheduling-context": {
"get": {
"tags": [
"Playouts"
],
"summary": "Decode a playout item's scheduling context",
"description": "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.",
"operationId": "GetPlayoutItemSchedulingContext",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/PlayoutItemSchedulingContextResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlayoutItemSchedulingContextResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PlayoutItemSchedulingContextResponseModel"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/rerun-collections": {
"get": {
"tags": [
@@ -19053,7 +19338,8 @@
"start",
"finish",
"duration",
"fillerKind"
"fillerKind",
"hasSchedulingContext"
],
"type": "object",
"properties": {
@@ -19086,6 +19372,20 @@
"$ref": "#/components/schemas/FillerKind"
}
]
},
"hasSchedulingContext": {
"type": "boolean"
}
}
},
"PlayoutItemSchedulingContextResponseModel": {
"required": [
"context"
],
"type": "object",
"properties": {
"context": {
"type": "string"
}
}
},
@@ -19097,7 +19397,8 @@
"scheduleKind",
"scheduleName",
"dailyRebuildTime",
"buildStatus"
"buildStatus",
"playoutMode"
],
"type": "object",
"properties": {
@@ -19133,6 +19434,9 @@
"$ref": "#/components/schemas/PlayoutBuildStatusResponseModel"
}
]
},
"playoutMode": {
"$ref": "#/components/schemas/ChannelPlayoutMode"
}
}
},
@@ -21394,6 +21698,24 @@
}
}
},
"UpdateCollectionCustomOrderRequest": {
"required": [
"mediaItemIds"
],
"type": "object",
"properties": {
"mediaItemIds": {
"type": [
"null",
"array"
],
"items": {
"type": "integer",
"format": "int32"
}
}
}
},
"UpdateCollectionRequest": {
"required": [
"name",