merge main into feat/141-media-detail (post-#182); regenerate OpenAPI artifacts
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 23:01:17 +02:00
co-authored by Claude Fable 5
31 changed files with 2199 additions and 83 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")]
+438
View File
@@ -6690,6 +6690,251 @@
}
}
},
"/api/playouts/{id}/blocks": {
"get": {
"tags": [
"Playouts"
],
"summary": "Get the blocks scheduled by a block playout",
"description": "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.",
"operationId": "GetPlayoutBlocks",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BlockResponseModel"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BlockResponseModel"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BlockResponseModel"
}
}
}
}
},
"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/playouts/{id}/blocks/{blockId}/history": {
"get": {
"tags": [
"Playouts"
],
"summary": "Get a block's playout history",
"description": "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}.",
"operationId": "GetPlayoutBlockHistory",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "blockId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "pageNum",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 0
}
},
{
"name": "pageSize",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 100
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/PagedPlayoutHistoryResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/PagedPlayoutHistoryResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PagedPlayoutHistoryResponseModel"
}
}
}
},
"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/playouts/history/{id}": {
"get": {
"tags": [
"Playouts"
],
"summary": "Decode a playout history row",
"description": "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.",
"operationId": "GetPlayoutHistoryDetails",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/PlayoutHistoryDetailsResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlayoutHistoryDetailsResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PlayoutHistoryDetailsResponseModel"
}
}
}
},
"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/reset-all": {
"post": {
"tags": [
@@ -10663,6 +10908,83 @@
}
}
},
"/api/troubleshoot/validate-schedule": {
"post": {
"tags": [
"Troubleshooting"
],
"summary": "Validate a sequential schedule YAML document",
"description": "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.",
"operationId": "ValidateSequentialSchedule",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/ValidateSequentialScheduleRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateSequentialScheduleRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ValidateSequentialScheduleRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/ValidateSequentialScheduleRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ValidateSequentialScheduleResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateSequentialScheduleResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ValidateSequentialScheduleResponseModel"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/troubleshoot/playback.m3u8": {
"head": {
"tags": [
@@ -16175,6 +16497,25 @@
}
}
},
"PagedPlayoutHistoryResponseModel": {
"required": [
"totalCount",
"page"
],
"type": "object",
"properties": {
"totalCount": {
"type": "integer",
"format": "int32"
},
"page": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PlayoutHistoryResponseModel"
}
}
}
},
"PagedPlayoutItemsResponseModel": {
"required": [
"totalCount",
@@ -16535,6 +16876,63 @@
}
}
},
"PlayoutHistoryDetailsResponseModel": {
"required": [
"playbackOrder",
"collectionType",
"name",
"mediaItemType",
"mediaItemTitle"
],
"type": "object",
"properties": {
"playbackOrder": {
"$ref": "#/components/schemas/PlaybackOrder"
},
"collectionType": {
"$ref": "#/components/schemas/CollectionType"
},
"name": {
"type": "string"
},
"mediaItemType": {
"type": "string"
},
"mediaItemTitle": {
"type": "string"
}
}
},
"PlayoutHistoryResponseModel": {
"required": [
"id",
"when",
"finish",
"key",
"details"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"when": {
"type": "string",
"format": "date-time"
},
"finish": {
"type": "string",
"format": "date-time"
},
"key": {
"type": "string"
},
"details": {
"type": "string"
}
}
},
"PlayoutItemResponseModel": {
"required": [
"title",
@@ -19468,6 +19866,46 @@
],
"type": "string"
},
"ValidateSequentialScheduleRequest": {
"required": [
"yaml",
"isImport"
],
"type": "object",
"properties": {
"yaml": {
"type": [
"null",
"string"
]
},
"isImport": {
"type": "boolean"
}
}
},
"ValidateSequentialScheduleResponseModel": {
"required": [
"isValid",
"messages",
"json"
],
"type": "object",
"properties": {
"isValid": {
"type": "boolean"
},
"messages": {
"type": "array",
"items": {
"type": "string"
}
},
"json": {
"type": "string"
}
}
},
"VideoScanKind": {
"enum": [
"Unknown",