feat(api): block-playout history + sequential-schedule validation endpoints

Adds the REST surface for the #145 troubleshooting leftovers (#158 items 4-5):

- GET /api/playouts/{id}/blocks - blocks a block playout schedules
- GET /api/playouts/{id}/blocks/{blockId}/history - paged block history
- GET /api/playouts/history/{id} - decode a history row by id
- POST /api/troubleshoot/validate-schedule - validate sequential YAML

New MediatR queries GetPlayoutHistoryDetails (Either, 404 unknown row /
422 malformed JSON) and ValidateSequentialSchedule (wraps
ISequentialScheduleValidator, never throws). DecodePlayoutHistoryHandler
and the new by-id handler now share PlayoutHistoryDecoder. Regenerated
openapi/v1.json.

Refs #145 #158

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:10:37 +02:00
co-authored by Claude Fable 5
parent e58da21a56
commit f0a423c2de
15 changed files with 777 additions and 81 deletions
@@ -2,6 +2,8 @@ using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Playouts;
@@ -18,6 +20,8 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
public class PlayoutController(IMediator mediator) : ControllerBase
{
private const int MaxPageSize = 100;
[HttpGet("/api/playouts", Name = "GetPlayouts")]
[Tags("Playouts")]
[EndpointSummary("List playouts")]
@@ -430,6 +434,82 @@ public class PlayoutController(IMediator mediator) : ControllerBase
return new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList());
}
[HttpGet("/api/playouts/{id:int}/blocks", Name = "GetPlayoutBlocks")]
[Tags("Playouts")]
[EndpointSummary("Get the blocks scheduled by a block playout")]
[EndpointDescription(
"Lists the distinct blocks reachable through a Block playout's templates, ordered by group then name. " +
"A playout with no templates (including non-Block playouts) returns an empty list.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<BlockResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetBlocks(int id, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
List<BlockViewModel> blocks = await mediator.Send(new GetAllBlocksForPlayout(id), cancellationToken);
return new OkObjectResult(blocks.Map(ToBlockResponse).ToList());
}
[HttpGet("/api/playouts/{id:int}/blocks/{blockId:int}/history", Name = "GetPlayoutBlockHistory")]
[Tags("Playouts")]
[EndpointSummary("Get a block's playout history")]
[EndpointDescription(
"Returns the paged scheduling history for a single block within a block playout, oldest first. Each row's " +
"Key and Details carry raw JSON; decode a row via GET /api/playouts/history/{id}.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedPlayoutHistoryResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetBlockHistory(
int id,
int blockId,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
PagedPlayoutHistoryViewModel result = await mediator.Send(
new GetBlockPlayoutHistory(id, blockId, clampedPageNum, clampedPageSize),
cancellationToken);
return new OkObjectResult(
new PagedPlayoutHistoryResponseModel(
result.TotalCount,
result.Page.Map(ToHistoryResponse).ToList()));
}
[HttpGet("/api/playouts/history/{id:int}", Name = "GetPlayoutHistoryDetails")]
[Tags("Playouts")]
[EndpointSummary("Decode a playout history row")]
[EndpointDescription(
"Decodes a single playout history row (by its id) into its playback order, collection, and media-item " +
"details. Returns 422 if the row's stored Key/Details JSON cannot be decoded.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutHistoryDetailsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> GetHistoryDetails(int id, CancellationToken cancellationToken)
{
Either<BaseError, PlayoutHistoryDetailsViewModel> result =
await mediator.Send(new GetPlayoutHistoryDetails(id), cancellationToken);
return result.Match(
Left: error => error.ToErrorResult(),
Right: vm => (IActionResult)new OkObjectResult(ToDetailsResponse(vm)));
}
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
[Tags("Playouts")]
[EndpointSummary("Reset all playouts")]
@@ -501,6 +581,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
return Option<BaseError>.None;
}
private static BlockResponseModel ToBlockResponse(BlockViewModel vm) =>
new(vm.Id, vm.GroupId, vm.GroupName, vm.Name, vm.Minutes, vm.StopScheduling);
private static PlayoutHistoryResponseModel ToHistoryResponse(PlayoutHistoryViewModel vm) =>
new(vm.Id, vm.When, vm.Finish, vm.Key, vm.Details);
private static PlayoutHistoryDetailsResponseModel ToDetailsResponse(PlayoutHistoryDetailsViewModel vm) =>
new(vm.PlaybackOrder, vm.CollectionType, vm.Name, vm.MediaItemType, vm.MediaItemTitle);
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
PlayoutResponseModel.From(
vm.PlayoutId,
@@ -0,0 +1,8 @@
using ErsatzTV.Application.Troubleshooting.Queries;
namespace ErsatzTV.Controllers.Api.Requests;
public record ValidateSequentialScheduleRequest(string Yaml, bool IsImport)
{
public ValidateSequentialSchedule ToQuery() => new(Yaml, IsImport);
}
@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.IO.Abstractions;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -6,6 +7,7 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Troubleshooting;
using ErsatzTV.Core.Domain;
@@ -71,6 +73,36 @@ public class TroubleshootController(
info.VideoToolboxCapabilities);
}
[HttpPost("api/troubleshoot/validate-schedule", Name = "ValidateSequentialSchedule")]
[Tags("Troubleshooting")]
[EndpointSummary("Validate a sequential schedule YAML document")]
[EndpointDescription(
"Validates a sequential-schedule YAML string against the full (or import) schema. Returns whether it is " +
"valid, any validation messages, and the JSON conversion of the YAML. Parse/validator errors are reported " +
"as messages (IsValid=false), never as a 500.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ValidateSequentialScheduleResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ValidateSchedule(
[Required] [FromBody] ValidateSequentialScheduleRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Yaml))
{
return new BadRequestObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation failed",
Detail = "[Yaml] must not be empty"
});
}
ValidateSequentialScheduleViewModel result = await mediator.Send(request.ToQuery(), cancellationToken);
return new OkObjectResult(
new ValidateSequentialScheduleResponseModel(result.IsValid, result.Messages, result.Json));
}
[HttpHead("api/troubleshoot/playback.m3u8")]
[HttpGet("api/troubleshoot/playback.m3u8")]
[Tags("Troubleshooting")]