From f0a423c2de191fde878aefd25f5683025d2c946c Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 22:10:37 +0200 Subject: [PATCH] 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 --- .../Queries/DecodePlayoutHistoryHandler.cs | 86 +--- .../Queries/GetPlayoutHistoryDetails.cs | 5 + .../GetPlayoutHistoryDetailsHandler.cs | 42 ++ .../Queries/PlayoutHistoryDecoder.cs | 99 ++++ .../Queries/ValidateSequentialSchedule.cs | 3 + .../ValidateSequentialScheduleHandler.cs | 25 + .../ValidateSequentialScheduleViewModel.cs | 3 + .../PagedPlayoutHistoryResponseModel.cs | 4 + .../PlayoutHistoryDetailsResponseModel.cs | 11 + .../Playouts/PlayoutHistoryResponseModel.cs | 9 + ...ValidateSequentialScheduleResponseModel.cs | 4 + ErsatzTV/Controllers/Api/PlayoutController.cs | 89 ++++ .../ValidateSequentialScheduleRequest.cs | 8 + .../Controllers/Api/TroubleshootController.cs | 32 ++ ErsatzTV/wwwroot/openapi/v1.json | 438 ++++++++++++++++++ 15 files changed, 777 insertions(+), 81 deletions(-) create mode 100644 ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetails.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetailsHandler.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Queries/PlayoutHistoryDecoder.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialSchedule.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialScheduleHandler.cs create mode 100644 ErsatzTV.Application/Troubleshooting/ValidateSequentialScheduleViewModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PagedPlayoutHistoryResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutHistoryDetailsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutHistoryResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Troubleshooting/ValidateSequentialScheduleResponseModel.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/ValidateSequentialScheduleRequest.cs diff --git a/ErsatzTV.Application/Troubleshooting/Queries/DecodePlayoutHistoryHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/DecodePlayoutHistoryHandler.cs index fd404fa5d..7aa6a1c14 100644 --- a/ErsatzTV.Application/Troubleshooting/Queries/DecodePlayoutHistoryHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Queries/DecodePlayoutHistoryHandler.cs @@ -1,8 +1,5 @@ -using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; -using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; namespace ErsatzTV.Application.Troubleshooting.Queries; @@ -15,83 +12,10 @@ public class DecodePlayoutHistoryHandler(IDbContextFactory dbContextF { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - var decodedKey = JsonConvert.DeserializeObject(request.PlayoutHistory.Key); - - PlaybackOrder playbackOrder = decodedKey.PlaybackOrder ?? PlaybackOrder.None; - CollectionType collectionType = decodedKey.CollectionType ?? CollectionType.Collection; - - string name = string.Empty; - - switch (collectionType) - { - case CollectionType.Collection: - name = await dbContext.Collections - .AsNoTracking() - .Where(c => c.Id == (decodedKey.CollectionId ?? 0)) - .Map(c => c.Name) - .FirstOrDefaultAsync(cancellationToken); - break; - case CollectionType.SmartCollection: - name = await dbContext.SmartCollections - .AsNoTracking() - .Where(c => c.Id == (decodedKey.SmartCollectionId ?? 0)) - .Map(c => c.Name) - .FirstOrDefaultAsync(cancellationToken); - break; - } - - string mediaItemType = string.Empty; - string mediaItemTitle = string.Empty; - - Details details = JsonConvert.DeserializeObject
(request.PlayoutHistory.Details); - if (details?.MediaItemId != null) - { - Option maybeMediaItem = await dbContext.MediaItems - .AsNoTracking() - .Include(i => i.LibraryPath) - .ThenInclude(lp => lp.Library) - .ThenInclude(l => l.MediaSource) - .Include(i => (i as Movie).MovieMetadata) - .Include(i => (i as Episode).EpisodeMetadata) - .Include(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .Include(i => (i as OtherVideo).OtherVideoMetadata) - .Include(i => (i as Image).ImageMetadata) - .Include(i => (i as RemoteStream).RemoteStreamMetadata) - .Include(i => (i as Song).SongMetadata) - .Include(i => (i as MusicVideo).MusicVideoMetadata) - .Include(i => (i as MusicVideo).Artist) - .ThenInclude(a => a.ArtistMetadata) - .SelectOneAsync(i => i.Id, i => i.Id == details.MediaItemId, cancellationToken); - - foreach (var mediaItem in maybeMediaItem) - { - mediaItemType = mediaItem switch - { - Episode => "Episode", - Movie => "Movie", - MusicVideo => "Music Video", - OtherVideo => "Other Video", - Song => "Song", - Image => "Image", - RemoteStream => "Remote Stream", - _ => $"Unknown ({mediaItem.GetType().Name})" - }; - - mediaItemTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option.None); - } - } - - return new PlayoutHistoryDetailsViewModel(playbackOrder, collectionType, name, mediaItemType, mediaItemTitle); + return await PlayoutHistoryDecoder.Decode( + dbContext, + request.PlayoutHistory.Key, + request.PlayoutHistory.Details, + cancellationToken); } - - private sealed record BlockItemHistoryKey( - int? BlockId, - PlaybackOrder? PlaybackOrder, - CollectionType? CollectionType, - int? CollectionId, - int? SmartCollectionId); - - private sealed record Details(int? MediaItemId); } diff --git a/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetails.cs b/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetails.cs new file mode 100644 index 000000000..ac63a30e9 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetails.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public record GetPlayoutHistoryDetails(int Id) : IRequest>; diff --git a/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetailsHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetailsHandler.cs new file mode 100644 index 000000000..e2eee4345 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/GetPlayoutHistoryDetailsHandler.cs @@ -0,0 +1,42 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Errors; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public class GetPlayoutHistoryDetailsHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetPlayoutHistoryDetails request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybeHistory = await dbContext.PlayoutHistory + .AsNoTracking() + .SelectOneAsync(ph => ph.Id, ph => ph.Id == request.Id, cancellationToken); + + foreach (PlayoutHistory history in maybeHistory) + { + try + { + return await PlayoutHistoryDecoder.Decode( + dbContext, + history.Key, + history.Details, + cancellationToken); + } + catch (Exception ex) + { + // old/corrupt rows may carry malformed Key/Details JSON; surface a 422 rather than a 500 + return BaseError.New($"Unable to decode playout history: {ex.Message}"); + } + } + + return new NotFoundError($"Playout history {request.Id} does not exist"); + } +} diff --git a/ErsatzTV.Application/Troubleshooting/Queries/PlayoutHistoryDecoder.cs b/ErsatzTV.Application/Troubleshooting/Queries/PlayoutHistoryDecoder.cs new file mode 100644 index 000000000..e59759270 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/PlayoutHistoryDecoder.cs @@ -0,0 +1,99 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; + +namespace ErsatzTV.Application.Troubleshooting.Queries; + +// Shared decode logic for a single PlayoutHistory row's Key/Details JSON. Both +// DecodePlayoutHistoryHandler (Blazor) and GetPlayoutHistoryDetailsHandler (API) use this +// so the collection/media-item lookup lives in exactly one place. +internal static class PlayoutHistoryDecoder +{ + public static async Task Decode( + TvContext dbContext, + string key, + string details, + CancellationToken cancellationToken) + { + var decodedKey = JsonConvert.DeserializeObject(key); + + PlaybackOrder playbackOrder = decodedKey.PlaybackOrder ?? PlaybackOrder.None; + CollectionType collectionType = decodedKey.CollectionType ?? CollectionType.Collection; + + string name = string.Empty; + + switch (collectionType) + { + case CollectionType.Collection: + name = await dbContext.Collections + .AsNoTracking() + .Where(c => c.Id == (decodedKey.CollectionId ?? 0)) + .Map(c => c.Name) + .FirstOrDefaultAsync(cancellationToken); + break; + case CollectionType.SmartCollection: + name = await dbContext.SmartCollections + .AsNoTracking() + .Where(c => c.Id == (decodedKey.SmartCollectionId ?? 0)) + .Map(c => c.Name) + .FirstOrDefaultAsync(cancellationToken); + break; + } + + string mediaItemType = string.Empty; + string mediaItemTitle = string.Empty; + + Details decodedDetails = JsonConvert.DeserializeObject
(details); + if (decodedDetails?.MediaItemId != null) + { + Option maybeMediaItem = await dbContext.MediaItems + .AsNoTracking() + .Include(i => i.LibraryPath) + .ThenInclude(lp => lp.Library) + .ThenInclude(l => l.MediaSource) + .Include(i => (i as Movie).MovieMetadata) + .Include(i => (i as Episode).EpisodeMetadata) + .Include(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .Include(i => (i as OtherVideo).OtherVideoMetadata) + .Include(i => (i as Image).ImageMetadata) + .Include(i => (i as RemoteStream).RemoteStreamMetadata) + .Include(i => (i as Song).SongMetadata) + .Include(i => (i as MusicVideo).MusicVideoMetadata) + .Include(i => (i as MusicVideo).Artist) + .ThenInclude(a => a.ArtistMetadata) + .SelectOneAsync(i => i.Id, i => i.Id == decodedDetails.MediaItemId, cancellationToken); + + foreach (var mediaItem in maybeMediaItem) + { + mediaItemType = mediaItem switch + { + Episode => "Episode", + Movie => "Movie", + MusicVideo => "Music Video", + OtherVideo => "Other Video", + Song => "Song", + Image => "Image", + RemoteStream => "Remote Stream", + _ => $"Unknown ({mediaItem.GetType().Name})" + }; + + mediaItemTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option.None); + } + } + + return new PlayoutHistoryDetailsViewModel(playbackOrder, collectionType, name, mediaItemType, mediaItemTitle); + } + + private sealed record BlockItemHistoryKey( + int? BlockId, + PlaybackOrder? PlaybackOrder, + CollectionType? CollectionType, + int? CollectionId, + int? SmartCollectionId); + + private sealed record Details(int? MediaItemId); +} diff --git a/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialSchedule.cs b/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialSchedule.cs new file mode 100644 index 000000000..65e48b048 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialSchedule.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public record ValidateSequentialSchedule(string Yaml, bool IsImport) : IRequest; diff --git a/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialScheduleHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialScheduleHandler.cs new file mode 100644 index 000000000..fcd70ca7b --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/ValidateSequentialScheduleHandler.cs @@ -0,0 +1,25 @@ +using ErsatzTV.Core.Interfaces.Scheduling; + +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public class ValidateSequentialScheduleHandler(ISequentialScheduleValidator validator) + : IRequestHandler +{ + public async Task Handle( + ValidateSequentialSchedule request, + CancellationToken cancellationToken) + { + try + { + // ToJson runs first and can throw on malformed YAML (GetValidationMessages catches its + // own exceptions internally); mirrors the Blazor YamlValidator ordering. + string json = validator.ToJson(request.Yaml); + IList messages = await validator.GetValidationMessages(request.Yaml, request.IsImport); + return new ValidateSequentialScheduleViewModel(messages.Count == 0, messages.ToList(), json); + } + catch (Exception ex) + { + return new ValidateSequentialScheduleViewModel(false, [ex.Message], string.Empty); + } + } +} diff --git a/ErsatzTV.Application/Troubleshooting/ValidateSequentialScheduleViewModel.cs b/ErsatzTV.Application/Troubleshooting/ValidateSequentialScheduleViewModel.cs new file mode 100644 index 000000000..dd9574008 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/ValidateSequentialScheduleViewModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Troubleshooting; + +public record ValidateSequentialScheduleViewModel(bool IsValid, List Messages, string Json); diff --git a/ErsatzTV.Core/Api/Playouts/PagedPlayoutHistoryResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PagedPlayoutHistoryResponseModel.cs new file mode 100644 index 000000000..17b57221b --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PagedPlayoutHistoryResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Playouts; + +public record PagedPlayoutHistoryResponseModel(int TotalCount, List Page); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutHistoryDetailsResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutHistoryDetailsResponseModel.cs new file mode 100644 index 000000000..e7477bb4f --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutHistoryDetailsResponseModel.cs @@ -0,0 +1,11 @@ +#nullable enable +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutHistoryDetailsResponseModel( + PlaybackOrder PlaybackOrder, + CollectionType CollectionType, + string Name, + string MediaItemType, + string MediaItemTitle); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutHistoryResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutHistoryResponseModel.cs new file mode 100644 index 000000000..27cc88777 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutHistoryResponseModel.cs @@ -0,0 +1,9 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutHistoryResponseModel( + int Id, + DateTimeOffset When, + DateTimeOffset Finish, + string Key, + string Details); diff --git a/ErsatzTV.Core/Api/Troubleshooting/ValidateSequentialScheduleResponseModel.cs b/ErsatzTV.Core/Api/Troubleshooting/ValidateSequentialScheduleResponseModel.cs new file mode 100644 index 000000000..14795eb40 --- /dev/null +++ b/ErsatzTV.Core/Api/Troubleshooting/ValidateSequentialScheduleResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Troubleshooting; + +public record ValidateSequentialScheduleResponseModel(bool IsValid, List Messages, string Json); diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index c6294b063..636986c75 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -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), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetBlocks(int id, CancellationToken cancellationToken) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List 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 GetBlockHistory( + int id, + int blockId, + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + Option 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 GetHistoryDetails(int id, CancellationToken cancellationToken) + { + Either 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.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, diff --git a/ErsatzTV/Controllers/Api/Requests/ValidateSequentialScheduleRequest.cs b/ErsatzTV/Controllers/Api/Requests/ValidateSequentialScheduleRequest.cs new file mode 100644 index 000000000..02d33f4a5 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ValidateSequentialScheduleRequest.cs @@ -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); +} diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index dc8daee88..cadc54c03 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -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 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")] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 77259c594..a3520c252 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -6315,6 +6315,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": [ @@ -10164,6 +10409,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": [ @@ -15149,6 +15471,25 @@ } } }, + "PagedPlayoutHistoryResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayoutHistoryResponseModel" + } + } + } + }, "PagedPlayoutItemsResponseModel": { "required": [ "totalCount", @@ -15509,6 +15850,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", @@ -18276,6 +18674,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" + } + } + }, "WatermarkFullResponseModel": { "required": [ "id",