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:
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user