From 57bb320af875510a25b6a138fbad72c8f7bc57a5 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 19:22:39 +0200 Subject: [PATCH] feat(api): REST endpoints for decos, deco groups, playout default deco (#144 S3 (#162)) Adds DecoController (groups + decos CRUD + full-state PUT), playlist and artist/multi-collection search picker wrappers, a PlaylistController for the break-content playlist cascade, and PUT /api/playouts/{id}/deco. Fixes the CreateDecoHandler missing deco-group existence check (FK 500 -> 422), matching the CreateBlock/CreateTemplate precedent. ReplaceDecoRequest.ToCommand rejects Merge on the non-mergeable sections (422). Co-Authored-By: Claude Fable 5 --- .../Scheduling/Commands/CreateDecoHandler.cs | 20 +- .../PlaylistGroupResponseModel.cs | 4 + .../MediaCollections/PlaylistResponseModel.cs | 4 + .../DecoBreakContentResponseModel.cs | 17 + .../Api/Scheduling/DecoGroupResponseModel.cs | 4 + .../Scheduling/DecoListItemResponseModel.cs | 4 + .../Api/Scheduling/DecoResponseModel.cs | 34 + .../Controllers/ApiControllerSecurityTests.cs | 1 + .../ApiErrorResponseMetadataTests.cs | 11 + .../Controllers/DecoControllerTests.cs | 399 +++++ ErsatzTV/Controllers/Api/DecoController.cs | 237 +++ .../Controllers/Api/PlaylistController.cs | 37 + ErsatzTV/Controllers/Api/PlayoutController.cs | 44 + .../Api/Requests/CreateDecoGroupRequest.cs | 8 + .../Api/Requests/CreateDecoRequest.cs | 8 + .../Api/Requests/ReplaceDecoRequest.cs | 91 ++ .../Api/Requests/UpdateDefaultDecoRequest.cs | 8 + ErsatzTV/Controllers/Api/SearchController.cs | 32 + ErsatzTV/wwwroot/openapi/v1.json | 1441 +++++++++++++++++ 19 files changed, 2402 insertions(+), 2 deletions(-) create mode 100644 ErsatzTV.Core/Api/MediaCollections/PlaylistGroupResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaCollections/PlaylistResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/DecoBreakContentResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/DecoGroupResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/DecoListItemResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/DecoResponseModel.cs create mode 100644 ErsatzTV.Tests/Controllers/DecoControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/DecoController.cs create mode 100644 ErsatzTV/Controllers/Api/PlaylistController.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateDecoGroupRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateDecoRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/ReplaceDecoRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateDefaultDecoRequest.cs diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateDecoHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CreateDecoHandler.cs index ad9ab33b4..871bac52e 100644 --- a/ErsatzTV.Application/Scheduling/Commands/CreateDecoHandler.cs +++ b/ErsatzTV.Application/Scheduling/Commands/CreateDecoHandler.cs @@ -25,8 +25,12 @@ public class CreateDecoHandler(IDbContextFactory dbContextFactory) return Mapper.ProjectToViewModel(deco); } - private static async Task> Validate(TvContext dbContext, CreateDeco request) => - await ValidateDecoName(dbContext, request).MapT(name => new Deco + private static async Task> Validate(TvContext dbContext, CreateDeco request) + { + Validation decoGroupValidation = await ValidateDecoGroupExists(dbContext, request); + Validation nameValidation = await ValidateDecoName(dbContext, request); + + return (decoGroupValidation, nameValidation).Apply((_, name) => new Deco { DecoGroupId = request.DecoGroupId, Name = name, @@ -34,6 +38,18 @@ public class CreateDecoHandler(IDbContextFactory dbContextFactory) DecoWatermarks = [], DecoGraphicsElements = [] }); + } + + private static async Task> ValidateDecoGroupExists( + TvContext dbContext, + CreateDeco request) + { + bool decoGroupExists = await dbContext.DecoGroups.AnyAsync(dg => dg.Id == request.DecoGroupId); + + return decoGroupExists + ? Success(Unit.Default) + : BaseError.New("Deco group does not exist"); + } private static async Task> ValidateDecoName( TvContext dbContext, diff --git a/ErsatzTV.Core/Api/MediaCollections/PlaylistGroupResponseModel.cs b/ErsatzTV.Core/Api/MediaCollections/PlaylistGroupResponseModel.cs new file mode 100644 index 000000000..07bb68534 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaCollections/PlaylistGroupResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.MediaCollections; + +public record PlaylistGroupResponseModel(int Id, string Name, int PlaylistCount, bool IsSystem); diff --git a/ErsatzTV.Core/Api/MediaCollections/PlaylistResponseModel.cs b/ErsatzTV.Core/Api/MediaCollections/PlaylistResponseModel.cs new file mode 100644 index 000000000..3ea9a63d6 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaCollections/PlaylistResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.MediaCollections; + +public record PlaylistResponseModel(int Id, int PlaylistGroupId, string Name, bool IsSystem); diff --git a/ErsatzTV.Core/Api/Scheduling/DecoBreakContentResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/DecoBreakContentResponseModel.cs new file mode 100644 index 000000000..52aadeb7d --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/DecoBreakContentResponseModel.cs @@ -0,0 +1,17 @@ +#nullable enable +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; + +namespace ErsatzTV.Core.Api.Scheduling; + +public record DecoBreakContentResponseModel( + int Id, + CollectionType CollectionType, + int? CollectionId, + int? MediaItemId, + int? MultiCollectionId, + int? SmartCollectionId, + int? PlaylistId, + int? PlaylistGroupId, + string? SelectionName, + DecoBreakPlacement Placement); diff --git a/ErsatzTV.Core/Api/Scheduling/DecoGroupResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/DecoGroupResponseModel.cs new file mode 100644 index 000000000..0f3b56013 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/DecoGroupResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record DecoGroupResponseModel(int Id, string Name, int DecoCount); diff --git a/ErsatzTV.Core/Api/Scheduling/DecoListItemResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/DecoListItemResponseModel.cs new file mode 100644 index 000000000..4cf8421b3 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/DecoListItemResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record DecoListItemResponseModel(int Id, int DecoGroupId, string DecoGroupName, string Name); diff --git a/ErsatzTV.Core/Api/Scheduling/DecoResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/DecoResponseModel.cs new file mode 100644 index 000000000..27f5846f9 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/DecoResponseModel.cs @@ -0,0 +1,34 @@ +#nullable enable +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; + +namespace ErsatzTV.Core.Api.Scheduling; + +public record DecoResponseModel( + int Id, + int DecoGroupId, + string DecoGroupName, + string Name, + DecoMode WatermarkMode, + List WatermarkIds, + bool UseWatermarkDuringFiller, + DecoMode GraphicsElementsMode, + List GraphicsElementIds, + bool UseGraphicsElementsDuringFiller, + DecoMode BreakContentMode, + List BreakContent, + DecoMode DefaultFillerMode, + CollectionType DefaultFillerCollectionType, + int? DefaultFillerCollectionId, + int? DefaultFillerMediaItemId, + int? DefaultFillerMultiCollectionId, + int? DefaultFillerSmartCollectionId, + string? DefaultFillerSelectionName, + bool DefaultFillerTrimToFit, + DecoMode DeadAirFallbackMode, + CollectionType DeadAirFallbackCollectionType, + int? DeadAirFallbackCollectionId, + int? DeadAirFallbackMediaItemId, + int? DeadAirFallbackMultiCollectionId, + int? DeadAirFallbackSmartCollectionId, + string? DeadAirFallbackSelectionName); diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index 7b711b9da..f7bc4b93c 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -28,6 +28,7 @@ public class ApiControllerSecurityTests typeof(BlockController), typeof(ChannelController), typeof(CollectionController), + typeof(DecoController), typeof(FFmpegProfileController), typeof(FillerPresetController), typeof(LibrariesController), diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index 92f830a51..025b0f7e2 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -93,6 +93,17 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(TemplateController), nameof(TemplateController.Replace), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(TemplateController), nameof(TemplateController.Copy), StatusCodes.Status404NotFound)] [TestCase(typeof(TemplateController), nameof(TemplateController.Copy), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(DecoController), nameof(DecoController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoController), nameof(DecoController.CreateGroup), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoController), nameof(DecoController.CreateGroup), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(DecoController), nameof(DecoController.DeleteGroup), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoController), nameof(DecoController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoController), nameof(DecoController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(DecoController), nameof(DecoController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoController), nameof(DecoController.Replace), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoController), nameof(DecoController.Replace), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)] diff --git a/ErsatzTV.Tests/Controllers/DecoControllerTests.cs b/ErsatzTV.Tests/Controllers/DecoControllerTests.cs new file mode 100644 index 000000000..38068d4ac --- /dev/null +++ b/ErsatzTV.Tests/Controllers/DecoControllerTests.cs @@ -0,0 +1,399 @@ +using System.Reflection; +using ErsatzTV.Application.Scheduling; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Scheduling; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class DecoControllerTests +{ + private DecoController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new DecoController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(DecoController.GetGroups), "GET", "/api/decos/groups"); + ShouldHaveActionRoute(nameof(DecoController.CreateGroup), "POST", "/api/decos/groups"); + ShouldHaveActionRoute(nameof(DecoController.DeleteGroup), "DELETE", "/api/decos/groups/{id:int}"); + ShouldHaveActionRoute(nameof(DecoController.GetAll), "GET", "/api/decos"); + ShouldHaveActionRoute(nameof(DecoController.GetById), "GET", "/api/decos/{id:int}"); + ShouldHaveActionRoute(nameof(DecoController.Create), "POST", "/api/decos"); + ShouldHaveActionRoute(nameof(DecoController.Delete), "DELETE", "/api/decos/{id:int}"); + ShouldHaveActionRoute(nameof(DecoController.Replace), "PUT", "/api/decos/{id:int}"); + } + + [Test] + public async Task GetGroups_Should_Map_ViewModels() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(3, "Bumpers", 2) }); + + List result = await _controller.GetGroups(CancellationToken.None); + + result.Count.ShouldBe(1); + result[0].Id.ShouldBe(3); + result[0].Name.ShouldBe("Bumpers"); + result[0].DecoCount.ShouldBe(2); + } + + [Test] + public async Task CreateGroup_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new DecoGroupViewModel(5, "Bumpers", 0))); + + IActionResult result = await _controller.CreateGroup( + new CreateDecoGroupRequest("Bumpers"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/decos/groups/5"); + created.Value.ShouldBeOfType().Name.ShouldBe("Bumpers"); + await _mediator.Received(1).Send( + Arg.Is(c => c.Name == "Bumpers"), + Arg.Any()); + } + + [Test] + public async Task CreateGroup_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.CreateGroup( + new CreateDecoGroupRequest(string.Empty), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task DeleteGroup_Should_Return_204_When_Exists() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(3, "Bumpers", 0) }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.DeleteGroup(3, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.DecoGroupId == 3), + Arg.Any()); + } + + [Test] + public async Task DeleteGroup_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + + IActionResult result = await _controller.DeleteGroup(99, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetAll_Should_Flatten_Decos_Across_Groups() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(1, "A", 1), new(2, "B", 1) }); + _mediator.Send(Arg.Is(q => q.DecoGroupId == 1), Arg.Any()) + .Returns(new List { MakeDeco(10, 1, "First") }); + _mediator.Send(Arg.Is(q => q.DecoGroupId == 2), Arg.Any()) + .Returns(new List { MakeDeco(20, 2, "Second") }); + + List result = await _controller.GetAll(CancellationToken.None); + + result.Count.ShouldBe(2); + result[0].Id.ShouldBe(10); + result[0].DecoGroupId.ShouldBe(1); + result[1].Id.ShouldBe(20); + } + + [Test] + public async Task GetById_Should_Return_200_And_Map_All_Sections() + { + DecoViewModel deco = MakeDeco( + 4, + 2, + "Movie Night", + watermarkMode: DecoMode.Merge, + breakContentMode: DecoMode.Override, + breakContent: + [ + new DecoBreakContentViewModel( + 7, + CollectionType.Playlist, + null, + null, + null, + null, + new ErsatzTV.Application.MediaCollections.PlaylistViewModel(9, 3, "Idents", false), + DecoBreakPlacement.BlockStart) + ]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(deco)); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + var body = result.ShouldBeOfType().Value.ShouldBeOfType(); + body.Id.ShouldBe(4); + body.WatermarkMode.ShouldBe(DecoMode.Merge); + body.BreakContentMode.ShouldBe(DecoMode.Override); + body.BreakContent.Count.ShouldBe(1); + body.BreakContent[0].PlaylistId.ShouldBe(9); + body.BreakContent[0].PlaylistGroupId.ShouldBe(3); + body.BreakContent[0].SelectionName.ShouldBe("Idents"); + body.BreakContent[0].Placement.ShouldBe(DecoBreakPlacement.BlockStart); + } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Create_Should_Return_201_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeDeco(8, 2, "Movie Night"))); + + IActionResult result = await _controller.Create( + new CreateDecoRequest(2, "Movie Night"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/decos/8"); + await _mediator.Received(1).Send( + Arg.Is(c => c.DecoGroupId == 2 && c.Name == "Movie Night"), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("Deco group does not exist"))); + + IActionResult result = await _controller.Create( + new CreateDecoRequest(99, "Movie Night"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_204_When_Exists() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeDeco(4, 2, "Movie Night"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Replace_Should_Return_200_And_Send_Full_State_With_Current_GroupId() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeDeco(4, 7, "Movie Night"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(LanguageExt.Unit.Default)); + + IActionResult result = await _controller.Replace( + 4, + MakeReplaceRequest( + "Movie Night", + breakContentMode: DecoMode.Override, + breakContent: + [ + new DecoBreakContentRequest( + 0, + CollectionType.Playlist, + null, + null, + null, + null, + 9, + DecoBreakPlacement.BlockStart) + ]), + CancellationToken.None); + + var body = result.ShouldBeOfType().Value.ShouldBeOfType(); + body.Id.ShouldBe(4); + await _mediator.Received(1).Send( + Arg.Is(c => + c.DecoId == 4 && + c.DecoGroupId == 7 && + c.Name == "Movie Night" && + c.BreakContentMode == DecoMode.Override && + c.BreakContent.Count == 1 && + c.BreakContent[0].PlaylistId == 9 && + c.BreakContent[0].Placement == DecoBreakPlacement.BlockStart), + Arg.Any()); + } + + [Test] + public async Task Replace_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Replace( + 4, + MakeReplaceRequest("Movie Night"), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Replace_Should_Return_422_On_Handler_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeDeco(4, 7, "Movie Night"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("Deco may only contain one [Block Start] break content"))); + + IActionResult result = await _controller.Replace( + 4, + MakeReplaceRequest("Movie Night"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Replace_Should_Return_422_On_Merge_Mode_Mismatch_Without_Dispatch() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeDeco(4, 7, "Movie Night"))); + + IActionResult result = await _controller.Replace( + 4, + MakeReplaceRequest("Movie Night", defaultFillerMode: DecoMode.Merge), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + private static DecoViewModel MakeDeco( + int id, + int groupId, + string name, + DecoMode watermarkMode = DecoMode.Inherit, + DecoMode breakContentMode = DecoMode.Inherit, + List? breakContent = null) => + new( + id, + groupId, + "Group", + name, + watermarkMode, + [], + false, + DecoMode.Inherit, + [], + false, + breakContentMode, + breakContent ?? [], + DecoMode.Inherit, + CollectionType.Collection, + null, + null, + null, + null, + false, + DecoMode.Inherit, + CollectionType.Collection, + null, + null, + null, + null); + + private static ReplaceDecoRequest MakeReplaceRequest( + string name, + DecoMode breakContentMode = DecoMode.Inherit, + DecoMode defaultFillerMode = DecoMode.Inherit, + List? breakContent = null) => + new( + name, + DecoMode.Inherit, + [], + false, + DecoMode.Inherit, + [], + false, + breakContentMode, + breakContent ?? [], + defaultFillerMode, + CollectionType.Collection, + null, + null, + null, + null, + false, + DecoMode.Inherit, + CollectionType.Collection, + null, + null, + null, + null); + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(DecoController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV/Controllers/Api/DecoController.cs b/ErsatzTV/Controllers/Api/DecoController.cs new file mode 100644 index 000000000..f2bbd716b --- /dev/null +++ b/ErsatzTV/Controllers/Api/DecoController.cs @@ -0,0 +1,237 @@ +using System.ComponentModel.DataAnnotations; +using ErsatzTV.Application.Scheduling; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Scheduling; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class DecoController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/decos/groups", Name = "GetDecoGroups")] + [Tags("Decos")] + [EndpointSummary("Get all deco groups")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetGroups(CancellationToken cancellationToken) + { + List groups = await mediator.Send(new GetAllDecoGroups(), cancellationToken); + return groups.Map(ProjectToResponseModel).ToList(); + } + + [HttpPost("/api/decos/groups")] + [Tags("Decos")] + [EndpointSummary("Create a deco group")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(DecoGroupResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task CreateGroup( + [Required] [FromBody] CreateDecoGroupRequest request, + CancellationToken cancellationToken) + { + Either result = + await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult( + vm => $"/api/decos/groups/{vm.Id}", + vm => ProjectToResponseModel(vm)); + } + + [HttpDelete("/api/decos/groups/{id:int}")] + [Tags("Decos")] + [EndpointSummary("Delete a deco group")] + [EndpointDescription( + "Deletes the deco group. The database cascade removes every deco in the group (and each deco's break " + + "content and watermark/graphics-element assignments); any playout that used a removed deco has its " + + "default deco cleared.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task DeleteGroup(int id, CancellationToken cancellationToken) + { + List groups = await mediator.Send(new GetAllDecoGroups(), cancellationToken); + if (groups.All(g => g.Id != id)) + { + return ApiResults.NotFoundProblem(); + } + + Option result = await mediator.Send(new DeleteDecoGroup(id), cancellationToken); + return result.Match( + Some: error => error.ToErrorResult(), + None: () => new NoContentResult()); + } + + [HttpGet("/api/decos")] + [Tags("Decos")] + [EndpointSummary("Get all decos")] + [EndpointDescription("Returns every deco as a flat list, ordered by group name then deco name.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) + { + List groups = await mediator.Send(new GetAllDecoGroups(), cancellationToken); + var result = new List(); + foreach (DecoGroupViewModel group in groups) + { + List decos = await mediator.Send(new GetDecosByDecoGroupId(group.Id), cancellationToken); + result.AddRange(decos.Map(d => new DecoListItemResponseModel(d.Id, d.DecoGroupId, d.DecoGroupName, d.Name))); + } + + return result; + } + + [HttpGet("/api/decos/{id:int}", Name = "GetDecoById")] + [Tags("Decos")] + [EndpointSummary("Get a deco by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(DecoResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetDecoById(id), cancellationToken); + return result.Map(ProjectToResponseModel).ToGetResult(); + } + + [HttpPost("/api/decos")] + [Tags("Decos")] + [EndpointSummary("Create a deco")] + [EndpointDescription("Creates a deco in the given deco group. The deco inherits everything by default.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(DecoResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreateDecoRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult( + vm => $"/api/decos/{vm.Id}", + vm => ProjectToResponseModel(vm)); + } + + [HttpDelete("/api/decos/{id:int}")] + [Tags("Decos")] + [EndpointSummary("Delete a deco")] + [EndpointDescription( + "Deletes the deco. Any playout that used it as its default deco has that default cleared.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Option existing = await mediator.Send(new GetDecoById(id), cancellationToken); + if (existing.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Option result = await mediator.Send(new DeleteDeco(id), cancellationToken); + return result.Match( + Some: error => error.ToErrorResult(), + None: () => new NoContentResult()); + } + + [HttpPut("/api/decos/{id:int}")] + [Tags("Decos")] + [EndpointSummary("Replace a deco")] + [EndpointDescription( + "Replaces the full state of the deco (name and all six sections). Send the entire desired state, including " + + "unchanged sections. Merge is only valid for the watermark and graphics-element modes. Break content allows " + + "at most one item per placement.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(DecoResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Replace( + int id, + [Required] [FromBody] ReplaceDecoRequest request, + CancellationToken cancellationToken) + { + Option maybeDeco = await mediator.Send(new GetDecoById(id), cancellationToken); + if (maybeDeco.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + int decoGroupId = maybeDeco.Map(d => d.DecoGroupId).IfNone(0); + + Either commandOrError = request.ToCommand(decoGroupId, id); + return await commandOrError.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async command => + { + Either result = await mediator.Send(command, cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + Option refreshed = await mediator.Send(new GetDecoById(id), cancellationToken); + return refreshed.Match( + Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)), + None: () => ApiResults.NotFoundProblem()); + }); + }); + } + + private static DecoGroupResponseModel ProjectToResponseModel(DecoGroupViewModel vm) => + new(vm.Id, vm.Name, vm.DecoCount); + + private static DecoResponseModel ProjectToResponseModel(DecoViewModel vm) => + new( + vm.Id, + vm.DecoGroupId, + vm.DecoGroupName, + vm.Name, + vm.WatermarkMode, + vm.Watermarks.Map(w => w.Id).ToList(), + vm.UseWatermarkDuringFiller, + vm.GraphicsElementsMode, + vm.GraphicsElements.Map(g => g.Id).ToList(), + vm.UseGraphicsElementsDuringFiller, + vm.BreakContentMode, + vm.BreakContent.Map(ProjectToResponseModel).ToList(), + vm.DefaultFillerMode, + vm.DefaultFillerCollectionType, + vm.DefaultFillerCollection?.Id, + vm.DefaultFillerMediaItem?.MediaItemId, + vm.DefaultFillerMultiCollection?.Id, + vm.DefaultFillerSmartCollection?.Id, + vm.DefaultFillerCollection?.Name + ?? vm.DefaultFillerMediaItem?.Name + ?? vm.DefaultFillerMultiCollection?.Name + ?? vm.DefaultFillerSmartCollection?.Name, + vm.DefaultFillerTrimToFit, + vm.DeadAirFallbackMode, + vm.DeadAirFallbackCollectionType, + vm.DeadAirFallbackCollection?.Id, + vm.DeadAirFallbackMediaItem?.MediaItemId, + vm.DeadAirFallbackMultiCollection?.Id, + vm.DeadAirFallbackSmartCollection?.Id, + vm.DeadAirFallbackCollection?.Name + ?? vm.DeadAirFallbackMediaItem?.Name + ?? vm.DeadAirFallbackMultiCollection?.Name + ?? vm.DeadAirFallbackSmartCollection?.Name); + + private static DecoBreakContentResponseModel ProjectToResponseModel(DecoBreakContentViewModel vm) => + new( + vm.Id, + vm.CollectionType, + vm.Collection?.Id, + vm.MediaItem?.MediaItemId, + vm.MultiCollection?.Id, + vm.SmartCollection?.Id, + vm.Playlist?.Id, + vm.Playlist?.PlaylistGroupId, + vm.Collection?.Name + ?? vm.MediaItem?.Name + ?? vm.MultiCollection?.Name + ?? vm.SmartCollection?.Name + ?? vm.Playlist?.Name, + vm.Placement); +} diff --git a/ErsatzTV/Controllers/Api/PlaylistController.cs b/ErsatzTV/Controllers/Api/PlaylistController.cs new file mode 100644 index 000000000..2fa92da20 --- /dev/null +++ b/ErsatzTV/Controllers/Api/PlaylistController.cs @@ -0,0 +1,37 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core.Api.MediaCollections; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class PlaylistController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/playlists/groups", Name = "GetPlaylistGroups")] + [Tags("Playlists")] + [EndpointSummary("Get all playlist groups")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetGroups(CancellationToken cancellationToken) + { + List groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken); + return groups.Map(g => new PlaylistGroupResponseModel(g.Id, g.Name, g.PlaylistCount, g.IsSystem)).ToList(); + } + + [HttpGet("/api/playlists", Name = "GetPlaylists")] + [Tags("Playlists")] + [EndpointSummary("Get playlists in a playlist group")] + [EndpointDescription("Returns the playlists in the given playlist group.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll( + [FromQuery] int playlistGroupId, + CancellationToken cancellationToken) + { + List playlists = + await mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroupId), cancellationToken); + return playlists.Map(p => new PlaylistResponseModel(p.Id, p.PlaylistGroupId, p.Name, p.IsSystem)).ToList(); + } +} diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index eeb29ff4c..00f0c441f 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.Playouts; +using ErsatzTV.Application.Scheduling; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; @@ -190,6 +191,49 @@ public class PlayoutController(IMediator mediator) : ControllerBase _ => BaseError.New("[ScheduleFile] is only valid for Sequential, Scripted, or ExternalJson playouts") }; + [HttpPut("/api/playouts/{id:int}/deco")] + [Tags("Playouts")] + [EndpointSummary("Set (or clear) a playout's default deco")] + [EndpointDescription("Assigns the default deco for a block playout. Send a null decoId to clear it.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task UpdateDefaultDeco( + int id, + [Required] [FromBody] UpdateDefaultDecoRequest request, + CancellationToken cancellationToken) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + if (request.DecoId is { } decoId) + { + Option maybeDeco = await mediator.Send(new GetDecoById(decoId), cancellationToken); + if (maybeDeco.IsNone) + { + return BaseError.New("Deco does not exist").ToErrorResult(); + } + } + + Option result = await mediator.Send(request.ToCommand(id), cancellationToken); + if (result.IsSome) + { + foreach (BaseError error in result) + { + return error.ToErrorResult(); + } + } + + Option refreshed = await mediator.Send(new GetPlayoutById(id), cancellationToken); + return refreshed.Match( + Some: vm => (IActionResult)new OkObjectResult(ToResponse(vm)), + None: () => ApiResults.NotFoundProblem()); + } + [HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")] [Tags("Playouts")] [EndpointSummary("Reset all playouts")] diff --git a/ErsatzTV/Controllers/Api/Requests/CreateDecoGroupRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateDecoGroupRequest.cs new file mode 100644 index 000000000..93b7d6040 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateDecoGroupRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateDecoGroupRequest(string Name) +{ + public CreateDecoGroup ToCommand() => new(Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateDecoRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateDecoRequest.cs new file mode 100644 index 000000000..e1e458296 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateDecoRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateDecoRequest(int DecoGroupId, string Name) +{ + public CreateDeco ToCommand() => new(DecoGroupId, Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ReplaceDecoRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplaceDecoRequest.cs new file mode 100644 index 000000000..a82e9db45 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplaceDecoRequest.cs @@ -0,0 +1,91 @@ +using ErsatzTV.Application.Scheduling; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record DecoBreakContentRequest( + int Id, + CollectionType CollectionType, + int? CollectionId, + int? MediaItemId, + int? MultiCollectionId, + int? SmartCollectionId, + int? PlaylistId, + DecoBreakPlacement Placement) +{ + public UpdateDecoBreakContent ToUpdateItem() => + new(Id, CollectionType, CollectionId, MediaItemId, MultiCollectionId, SmartCollectionId, PlaylistId, Placement); +} + +public record ReplaceDecoRequest( + string Name, + DecoMode WatermarkMode, + List WatermarkIds, + bool UseWatermarkDuringFiller, + DecoMode GraphicsElementsMode, + List GraphicsElementIds, + bool UseGraphicsElementsDuringFiller, + DecoMode BreakContentMode, + List BreakContent, + DecoMode DefaultFillerMode, + CollectionType DefaultFillerCollectionType, + int? DefaultFillerCollectionId, + int? DefaultFillerMediaItemId, + int? DefaultFillerMultiCollectionId, + int? DefaultFillerSmartCollectionId, + bool DefaultFillerTrimToFit, + DecoMode DeadAirFallbackMode, + CollectionType DeadAirFallbackCollectionType, + int? DeadAirFallbackCollectionId, + int? DeadAirFallbackMediaItemId, + int? DeadAirFallbackMultiCollectionId, + int? DeadAirFallbackSmartCollectionId) +{ + public Either ToCommand(int decoGroupId, int decoId) + { + // Merge is only valid for the watermark and graphics-element sections; the handler treats + // Merge on the other sections as "not overridden" (silently ignored), so reject it here. + if (BreakContentMode is DecoMode.Merge) + { + return BaseError.New("[BreakContentMode] does not support Merge; use Inherit, Disable, or Override"); + } + + if (DefaultFillerMode is DecoMode.Merge) + { + return BaseError.New("[DefaultFillerMode] does not support Merge; use Inherit, Disable, or Override"); + } + + if (DeadAirFallbackMode is DecoMode.Merge) + { + return BaseError.New("[DeadAirFallbackMode] does not support Merge; use Inherit, Disable, or Override"); + } + + return new UpdateDeco( + decoId, + decoGroupId, + Name, + WatermarkMode, + WatermarkIds ?? [], + UseWatermarkDuringFiller, + GraphicsElementsMode, + GraphicsElementIds ?? [], + UseGraphicsElementsDuringFiller, + BreakContentMode, + (BreakContent ?? []).Select(bc => bc.ToUpdateItem()).ToList(), + DefaultFillerMode, + DefaultFillerCollectionType, + DefaultFillerCollectionId, + DefaultFillerMediaItemId, + DefaultFillerMultiCollectionId, + DefaultFillerSmartCollectionId, + DefaultFillerTrimToFit, + DeadAirFallbackMode, + DeadAirFallbackCollectionType, + DeadAirFallbackCollectionId, + DeadAirFallbackMediaItemId, + DeadAirFallbackMultiCollectionId, + DeadAirFallbackSmartCollectionId); + } +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateDefaultDecoRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateDefaultDecoRequest.cs new file mode 100644 index 000000000..d79d49db4 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateDefaultDecoRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateDefaultDecoRequest(int? DecoId) +{ + public UpdateDefaultDeco ToCommand(int playoutId) => new(playoutId, DecoId); +} diff --git a/ErsatzTV/Controllers/Api/SearchController.cs b/ErsatzTV/Controllers/Api/SearchController.cs index 2fed2639a..c32691453 100644 --- a/ErsatzTV/Controllers/Api/SearchController.cs +++ b/ErsatzTV/Controllers/Api/SearchController.cs @@ -102,4 +102,36 @@ public class SearchController(IMediator mediator) : ControllerBase cancellationToken); return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList(); } + + [HttpGet("/api/search/artists", Name = "SearchArtists")] + [Tags("Search")] + [EndpointSummary("Search artists by name")] + [EndpointDescription("Returns matching artists as {id, name} options for scheduling editors.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> SearchArtists( + [FromQuery] string query = "", + CancellationToken cancellationToken = default) + { + List results = await mediator.Send( + new SearchArtists(query ?? string.Empty), + cancellationToken); + return results.Map(a => new SchedulingPickerOptionResponseModel(a.MediaItemId, a.Name)).ToList(); + } + + [HttpGet("/api/search/multi-collections", Name = "SearchMultiCollections")] + [Tags("Search")] + [EndpointSummary("Search multi collections by name")] + [EndpointDescription("Returns matching multi collections as {id, name} options for scheduling editors.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> SearchMultiCollections( + [FromQuery] string query = "", + CancellationToken cancellationToken = default) + { + List results = await mediator.Send( + new SearchMultiCollections(query ?? string.Empty), + cancellationToken); + return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList(); + } } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index fb3b9e7ed..58437257c 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2725,6 +2725,528 @@ } } }, + "/api/decos/groups": { + "get": { + "tags": [ + "Decos" + ], + "summary": "Get all deco groups", + "operationId": "GetDecoGroups", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoGroupResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoGroupResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoGroupResponseModel" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Decos" + ], + "summary": "Create a deco group", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoGroupRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoGroupRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoGroupRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoGroupRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DecoGroupResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecoGroupResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DecoGroupResponseModel" + } + } + } + }, + "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/decos/groups/{id}": { + "delete": { + "tags": [ + "Decos" + ], + "summary": "Delete a deco group", + "description": "Deletes the deco group. The database cascade removes every deco in the group (and each deco's break content and watermark/graphics-element assignments); any playout that used a removed deco has its default deco cleared.", + "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" + } + } + } + } + } + } + }, + "/api/decos": { + "get": { + "tags": [ + "Decos" + ], + "summary": "Get all decos", + "description": "Returns every deco as a flat list, ordered by group name then deco name.", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoListItemResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoListItemResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoListItemResponseModel" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Decos" + ], + "summary": "Create a deco", + "description": "Creates a deco in the given deco group. The deco inherits everything by default.", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DecoResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecoResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DecoResponseModel" + } + } + } + }, + "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/decos/{id}": { + "get": { + "tags": [ + "Decos" + ], + "summary": "Get a deco by id", + "operationId": "GetDecoById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DecoResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecoResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DecoResponseModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Decos" + ], + "summary": "Delete a deco", + "description": "Deletes the deco. Any playout that used it as its default deco has that default cleared.", + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Decos" + ], + "summary": "Replace a deco", + "description": "Replaces the full state of the deco (name and all six sections). Send the entire desired state, including unchanged sections. Merge is only valid for the watermark and graphics-element modes. Break content allows at most one item per placement.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceDecoRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceDecoRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceDecoRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceDecoRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DecoResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecoResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DecoResponseModel" + } + } + } + }, + "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/ffmpeg/profiles": { "get": { "tags": [ @@ -4088,6 +4610,97 @@ } } }, + "/api/playlists/groups": { + "get": { + "tags": [ + "Playlists" + ], + "summary": "Get all playlist groups", + "operationId": "GetPlaylistGroups", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistGroupResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistGroupResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistGroupResponseModel" + } + } + } + } + } + } + } + }, + "/api/playlists": { + "get": { + "tags": [ + "Playlists" + ], + "summary": "Get playlists in a playlist group", + "description": "Returns the playlists in the given playlist group.", + "operationId": "GetPlaylists", + "parameters": [ + { + "name": "playlistGroupId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistResponseModel" + } + } + } + } + } + } + } + }, "/api/playouts": { "get": { "tags": [ @@ -4592,6 +5205,113 @@ } } }, + "/api/playouts/{id}/deco": { + "put": { + "tags": [ + "Playouts" + ], + "summary": "Set (or clear) a playout's default deco", + "description": "Assigns the default deco for a block playout. Send a null decoId to clear it.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateDefaultDecoRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDefaultDecoRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDefaultDecoRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateDefaultDecoRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + } + } + }, + "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": [ @@ -5851,6 +6571,108 @@ } } }, + "/api/search/artists": { + "get": { + "tags": [ + "Search" + ], + "summary": "Search artists by name", + "description": "Returns matching artists as {id, name} options for scheduling editors.", + "operationId": "SearchArtists", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SchedulingPickerOptionResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SchedulingPickerOptionResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SchedulingPickerOptionResponseModel" + } + } + } + } + } + } + } + }, + "/api/search/multi-collections": { + "get": { + "tags": [ + "Search" + ], + "summary": "Search multi collections by name", + "description": "Returns matching multi collections as {id, name} options for scheduling editors.", + "operationId": "SearchMultiCollections", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SchedulingPickerOptionResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SchedulingPickerOptionResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SchedulingPickerOptionResponseModel" + } + } + } + } + } + } + } + }, "/api/sessions": { "get": { "tags": [ @@ -11021,6 +11843,39 @@ } } }, + "CreateDecoGroupRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + } + } + }, + "CreateDecoRequest": { + "required": [ + "decoGroupId", + "name" + ], + "type": "object", + "properties": { + "decoGroupId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + } + } + }, "CreateFFmpegProfileRequest": { "required": [ "name", @@ -11504,6 +12359,371 @@ } } }, + "DecoBreakContentRequest": { + "required": [ + "id", + "collectionType", + "collectionId", + "mediaItemId", + "multiCollectionId", + "smartCollectionId", + "playlistId", + "placement" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlistId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "placement": { + "$ref": "#/components/schemas/DecoBreakPlacement" + } + } + }, + "DecoBreakContentResponseModel": { + "required": [ + "id", + "collectionType", + "collectionId", + "mediaItemId", + "multiCollectionId", + "smartCollectionId", + "playlistId", + "playlistGroupId", + "selectionName", + "placement" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlistId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlistGroupId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "selectionName": { + "type": [ + "null", + "string" + ] + }, + "placement": { + "$ref": "#/components/schemas/DecoBreakPlacement" + } + } + }, + "DecoBreakPlacement": { + "enum": [ + "BlockStart", + "BlockFinish", + "BetweenBlockItems", + "ChapterMarkers" + ], + "type": "string" + }, + "DecoGroupResponseModel": { + "required": [ + "id", + "name", + "decoCount" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "decoCount": { + "type": "integer", + "format": "int32" + } + } + }, + "DecoListItemResponseModel": { + "required": [ + "id", + "decoGroupId", + "decoGroupName", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "decoGroupId": { + "type": "integer", + "format": "int32" + }, + "decoGroupName": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "DecoMode": { + "enum": [ + "Inherit", + "Disable", + "Override", + "Merge" + ], + "type": "string" + }, + "DecoResponseModel": { + "required": [ + "id", + "decoGroupId", + "decoGroupName", + "name", + "watermarkMode", + "watermarkIds", + "useWatermarkDuringFiller", + "graphicsElementsMode", + "graphicsElementIds", + "useGraphicsElementsDuringFiller", + "breakContentMode", + "breakContent", + "defaultFillerMode", + "defaultFillerCollectionType", + "defaultFillerCollectionId", + "defaultFillerMediaItemId", + "defaultFillerMultiCollectionId", + "defaultFillerSmartCollectionId", + "defaultFillerSelectionName", + "defaultFillerTrimToFit", + "deadAirFallbackMode", + "deadAirFallbackCollectionType", + "deadAirFallbackCollectionId", + "deadAirFallbackMediaItemId", + "deadAirFallbackMultiCollectionId", + "deadAirFallbackSmartCollectionId", + "deadAirFallbackSelectionName" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "decoGroupId": { + "type": "integer", + "format": "int32" + }, + "decoGroupName": { + "type": "string" + }, + "name": { + "type": "string" + }, + "watermarkMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "watermarkIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "useWatermarkDuringFiller": { + "type": "boolean" + }, + "graphicsElementsMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "graphicsElementIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "useGraphicsElementsDuringFiller": { + "type": "boolean" + }, + "breakContentMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "breakContent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoBreakContentResponseModel" + } + }, + "defaultFillerMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "defaultFillerCollectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "defaultFillerCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "defaultFillerMediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "defaultFillerMultiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "defaultFillerSmartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "defaultFillerSelectionName": { + "type": [ + "null", + "string" + ] + }, + "defaultFillerTrimToFit": { + "type": "boolean" + }, + "deadAirFallbackMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "deadAirFallbackCollectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "deadAirFallbackCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deadAirFallbackMediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deadAirFallbackMultiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deadAirFallbackSmartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deadAirFallbackSelectionName": { + "type": [ + "null", + "string" + ] + } + } + }, "DeleteMediaItemsRequest": { "required": [ "ids" @@ -12841,6 +14061,56 @@ ], "type": "string" }, + "PlaylistGroupResponseModel": { + "required": [ + "id", + "name", + "playlistCount", + "isSystem" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "playlistCount": { + "type": "integer", + "format": "int32" + }, + "isSystem": { + "type": "boolean" + } + } + }, + "PlaylistResponseModel": { + "required": [ + "id", + "playlistGroupId", + "name", + "isSystem" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "playlistGroupId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "isSystem": { + "type": "boolean" + } + } + }, "PlaylistViewModel": { "required": [ "id", @@ -13395,6 +14665,156 @@ } } }, + "ReplaceDecoRequest": { + "required": [ + "name", + "watermarkMode", + "watermarkIds", + "useWatermarkDuringFiller", + "graphicsElementsMode", + "graphicsElementIds", + "useGraphicsElementsDuringFiller", + "breakContentMode", + "breakContent", + "defaultFillerMode", + "defaultFillerCollectionType", + "defaultFillerCollectionId", + "defaultFillerMediaItemId", + "defaultFillerMultiCollectionId", + "defaultFillerSmartCollectionId", + "defaultFillerTrimToFit", + "deadAirFallbackMode", + "deadAirFallbackCollectionType", + "deadAirFallbackCollectionId", + "deadAirFallbackMediaItemId", + "deadAirFallbackMultiCollectionId", + "deadAirFallbackSmartCollectionId" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "watermarkMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "watermarkIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "useWatermarkDuringFiller": { + "type": "boolean" + }, + "graphicsElementsMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "graphicsElementIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "useGraphicsElementsDuringFiller": { + "type": "boolean" + }, + "breakContentMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "breakContent": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/DecoBreakContentRequest" + } + }, + "defaultFillerMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "defaultFillerCollectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "defaultFillerCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "defaultFillerMediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "defaultFillerMultiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "defaultFillerSmartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "defaultFillerTrimToFit": { + "type": "boolean" + }, + "deadAirFallbackMode": { + "$ref": "#/components/schemas/DecoMode" + }, + "deadAirFallbackCollectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "deadAirFallbackCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deadAirFallbackMediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deadAirFallbackMultiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "deadAirFallbackSmartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + } + } + }, "ReplaceScheduleItemsRequest": { "required": [ "items" @@ -14563,6 +15983,21 @@ } } }, + "UpdateDefaultDecoRequest": { + "required": [ + "decoId" + ], + "type": "object", + "properties": { + "decoId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + } + } + }, "UpdateFFmpegProfileRequest": { "required": [ "name", @@ -15489,6 +16924,9 @@ { "name": "Collections" }, + { + "name": "Decos" + }, { "name": "FFmpeg Profiles" }, @@ -15516,6 +16954,9 @@ { "name": "Media Sources" }, + { + "name": "Playlists" + }, { "name": "Playouts" },