From 6b5bc8e29ae2b8b37e588dde3797af6c51f9f6ec Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 17:42:40 +0200 Subject: [PATCH] feat(api): REST endpoints for blocks, block groups, items, preview + scheduling search pickers Adds BlockController (block groups + blocks CRUD, items GET, full replace, non-persisting preview) mirroring ScheduleController, plus four scheduling search picker endpoints on SearchController (collections, television shows, television seasons, smart collections). Response DTOs in ErsatzTV.Core/Api/ Scheduling; request DTOs with ToCommand index auto-assignment. #144 S1 (#162) Co-Authored-By: Claude Fable 5 --- .../Api/Scheduling/BlockGroupResponseModel.cs | 4 + .../Api/Scheduling/BlockItemResponseModel.cs | 24 + .../BlockPreviewItemResponseModel.cs | 8 + .../Api/Scheduling/BlockResponseModel.cs | 12 + .../Scheduling/BlockWithItemsResponseModel.cs | 13 + .../SchedulingPickerOptionResponseModel.cs | 8 + .../Controllers/ApiControllerSecurityTests.cs | 1 + .../ApiErrorResponseMetadataTests.cs | 11 + .../Controllers/BlockControllerTests.cs | 375 +++++ .../Controllers/SearchControllerTests.cs | 51 + ErsatzTV/Controllers/Api/BlockController.cs | 255 ++++ .../Api/Requests/BlockItemRequest.cs | 35 + .../Api/Requests/CreateBlockGroupRequest.cs | 8 + .../Api/Requests/CreateBlockRequest.cs | 8 + .../Api/Requests/ReplaceBlockRequest.cs | 20 + ErsatzTV/Controllers/Api/SearchController.cs | 67 + ErsatzTV/wwwroot/openapi/v1.json | 1306 +++++++++++++++++ 17 files changed, 2206 insertions(+) create mode 100644 ErsatzTV.Core/Api/Scheduling/BlockGroupResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/BlockItemResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/BlockPreviewItemResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/BlockResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/BlockWithItemsResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/SchedulingPickerOptionResponseModel.cs create mode 100644 ErsatzTV.Tests/Controllers/BlockControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/BlockController.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/BlockItemRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateBlockGroupRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateBlockRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/ReplaceBlockRequest.cs diff --git a/ErsatzTV.Core/Api/Scheduling/BlockGroupResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/BlockGroupResponseModel.cs new file mode 100644 index 000000000..7f292640d --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/BlockGroupResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record BlockGroupResponseModel(int Id, string Name); diff --git a/ErsatzTV.Core/Api/Scheduling/BlockItemResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/BlockItemResponseModel.cs new file mode 100644 index 000000000..b10cc0663 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/BlockItemResponseModel.cs @@ -0,0 +1,24 @@ +#nullable enable +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.Scheduling; + +public record BlockItemResponseModel( + int Id, + int Index, + CollectionType CollectionType, + int? CollectionId, + string? CollectionName, + int? MultiCollectionId, + string? MultiCollectionName, + int? SmartCollectionId, + string? SmartCollectionName, + int? MediaItemId, + string? MediaItemName, + string SearchTitle, + string SearchQuery, + PlaybackOrder PlaybackOrder, + bool IncludeInProgramGuide, + bool DisableWatermarks, + List WatermarkIds, + List GraphicsElementIds); diff --git a/ErsatzTV.Core/Api/Scheduling/BlockPreviewItemResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/BlockPreviewItemResponseModel.cs new file mode 100644 index 000000000..99bc04622 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/BlockPreviewItemResponseModel.cs @@ -0,0 +1,8 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record BlockPreviewItemResponseModel( + string Title, + TimeSpan Start, + TimeSpan Finish, + string Duration); diff --git a/ErsatzTV.Core/Api/Scheduling/BlockResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/BlockResponseModel.cs new file mode 100644 index 000000000..39a1e7be9 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/BlockResponseModel.cs @@ -0,0 +1,12 @@ +#nullable enable +using ErsatzTV.Core.Domain.Scheduling; + +namespace ErsatzTV.Core.Api.Scheduling; + +public record BlockResponseModel( + int Id, + int GroupId, + string GroupName, + string Name, + int Minutes, + BlockStopScheduling StopScheduling); diff --git a/ErsatzTV.Core/Api/Scheduling/BlockWithItemsResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/BlockWithItemsResponseModel.cs new file mode 100644 index 000000000..4e0a57cde --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/BlockWithItemsResponseModel.cs @@ -0,0 +1,13 @@ +#nullable enable +using ErsatzTV.Core.Domain.Scheduling; + +namespace ErsatzTV.Core.Api.Scheduling; + +public record BlockWithItemsResponseModel( + int Id, + int GroupId, + string GroupName, + string Name, + int Minutes, + BlockStopScheduling StopScheduling, + List Items); diff --git a/ErsatzTV.Core/Api/Scheduling/SchedulingPickerOptionResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/SchedulingPickerOptionResponseModel.cs new file mode 100644 index 000000000..decb1bcbe --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/SchedulingPickerOptionResponseModel.cs @@ -0,0 +1,8 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +/// +/// A lightweight {id, name} option used by the block/schedule editor pickers +/// (collection, television show, television season, smart collection search). +/// +public record SchedulingPickerOptionResponseModel(int Id, string Name); diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index 911befbe2..eea4d6858 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -25,6 +25,7 @@ public class ApiControllerSecurityTests { Type[] apiControllers = [ + typeof(BlockController), typeof(ChannelController), typeof(CollectionController), typeof(FFmpegProfileController), diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index d4d4a1b56..26769b640 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -68,6 +68,17 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status404NotFound)] [TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(BlockController), nameof(BlockController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(BlockController), nameof(BlockController.CreateGroup), StatusCodes.Status404NotFound)] + [TestCase(typeof(BlockController), nameof(BlockController.CreateGroup), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(BlockController), nameof(BlockController.DeleteGroup), StatusCodes.Status404NotFound)] + [TestCase(typeof(BlockController), nameof(BlockController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(BlockController), nameof(BlockController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(BlockController), nameof(BlockController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(BlockController), nameof(BlockController.GetItems), StatusCodes.Status404NotFound)] + [TestCase(typeof(BlockController), nameof(BlockController.Replace), StatusCodes.Status404NotFound)] + [TestCase(typeof(BlockController), nameof(BlockController.Replace), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(BlockController), nameof(BlockController.Preview), StatusCodes.Status404NotFound)] [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/BlockControllerTests.cs b/ErsatzTV.Tests/Controllers/BlockControllerTests.cs new file mode 100644 index 000000000..cc6723622 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/BlockControllerTests.cs @@ -0,0 +1,375 @@ +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; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class BlockControllerTests +{ + private BlockController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new BlockController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(BlockController.GetGroups), "GET", "/api/blocks/groups"); + ShouldHaveActionRoute(nameof(BlockController.CreateGroup), "POST", "/api/blocks/groups"); + ShouldHaveActionRoute(nameof(BlockController.DeleteGroup), "DELETE", "/api/blocks/groups/{id:int}"); + ShouldHaveActionRoute(nameof(BlockController.GetAll), "GET", "/api/blocks"); + ShouldHaveActionRoute(nameof(BlockController.GetById), "GET", "/api/blocks/{id:int}"); + ShouldHaveActionRoute(nameof(BlockController.Create), "POST", "/api/blocks"); + ShouldHaveActionRoute(nameof(BlockController.Delete), "DELETE", "/api/blocks/{id:int}"); + ShouldHaveActionRoute(nameof(BlockController.GetItems), "GET", "/api/blocks/{id:int}/items"); + ShouldHaveActionRoute(nameof(BlockController.Replace), "PUT", "/api/blocks/{id:int}"); + ShouldHaveActionRoute(nameof(BlockController.Preview), "POST", "/api/blocks/{id:int}/preview"); + } + + [Test] + public async Task CreateGroup_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new BlockGroupViewModel(5, "Prime Time"))); + + IActionResult result = await _controller.CreateGroup( + new CreateBlockGroupRequest("Prime Time"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/blocks/groups/5"); + created.Value.ShouldBeOfType().Name.ShouldBe("Prime Time"); + await _mediator.Received(1).Send( + Arg.Is(c => c.Name == "Prime Time"), + 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 CreateBlockGroupRequest(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, "Prime Time") }); + _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.BlockGroupId == 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_Map_ViewModels_To_Response_Models() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { MakeBlock(1, 2, "Morning") }); + + List result = await _controller.GetAll(CancellationToken.None); + + result.Count.ShouldBe(1); + result[0].Id.ShouldBe(1); + result[0].GroupId.ShouldBe(2); + result[0].Name.ShouldBe("Morning"); + } + + [Test] + public async Task Create_Should_Return_201_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeBlock(8, 2, "Morning"))); + + IActionResult result = await _controller.Create( + new CreateBlockRequest(2, "Morning"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/blocks/8"); + await _mediator.Received(1).Send( + Arg.Is(c => c.BlockGroupId == 2 && c.Name == "Morning"), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("dupe"))); + + IActionResult result = await _controller.Create( + new CreateBlockRequest(2, "Morning"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeBlock(4, 2, "Morning"))); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Id.ShouldBe(4); + } + + [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 Delete_Should_Return_204_When_Exists() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeBlock(4, 2, "Morning"))); + _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 GetItems_Should_Return_200_Ordered_By_Index() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeBlock(4, 2, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { MakeItem(20, 1), MakeItem(10, 0) }); + + IActionResult result = await _controller.GetItems(4, CancellationToken.None); + + var items = result.ShouldBeOfType().Value.ShouldBeOfType>(); + items.Count.ShouldBe(2); + items[0].Index.ShouldBe(0); + items[1].Index.ShouldBe(1); + } + + [Test] + public async Task GetItems_Should_Return_404_When_Block_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetItems(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Replace_Should_Return_200_With_Refreshed_Block_And_Assign_Index_And_GroupId() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeBlock(4, 7, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { MakeItem(10, 0), MakeItem(11, 1) }); + + IActionResult result = await _controller.Replace( + 4, + new ReplaceBlockRequest( + "Morning", + 60, + BlockStopScheduling.BeforeDurationEnd, + [MakeItemRequest(), MakeItemRequest()]), + CancellationToken.None); + + var body = result.ShouldBeOfType().Value.ShouldBeOfType(); + body.Id.ShouldBe(4); + body.Items.Count.ShouldBe(2); + await _mediator.Received(1).Send( + Arg.Is(c => + c.BlockId == 4 && + c.BlockGroupId == 7 && + c.Minutes == 60 && + c.StopScheduling == BlockStopScheduling.BeforeDurationEnd && + c.Items.Count == 2 && + c.Items[0].Index == 0 && + c.Items[1].Index == 1), + Arg.Any()); + } + + [Test] + public async Task Replace_Should_Return_404_When_Block_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Replace( + 4, + new ReplaceBlockRequest("Morning", 60, BlockStopScheduling.AfterDurationEnd, []), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Replace_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeBlock(4, 7, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad minutes"))); + + IActionResult result = await _controller.Replace( + 4, + new ReplaceBlockRequest("Morning", 7, BlockStopScheduling.AfterDurationEnd, []), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Preview_Should_Return_200_With_Preview_Items() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeBlock(4, 7, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + new("Show", TimeSpan.Zero, TimeSpan.FromMinutes(30), "00:30:00") + }); + + IActionResult result = await _controller.Preview( + 4, + new ReplaceBlockRequest("Morning", 60, BlockStopScheduling.AfterDurationEnd, [MakeItemRequest()]), + CancellationToken.None); + + var items = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + items.Count.ShouldBe(1); + items[0].Title.ShouldBe("Show"); + await _mediator.Received(1).Send( + Arg.Is(p => p.Data.BlockId == 4 && p.Data.BlockGroupId == 7), + Arg.Any()); + } + + [Test] + public async Task Preview_Should_Return_404_When_Block_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Preview( + 4, + new ReplaceBlockRequest("Morning", 60, BlockStopScheduling.AfterDurationEnd, []), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + private static BlockViewModel MakeBlock(int id, int groupId, string name) => + new(id, groupId, "Group", name, 30, BlockStopScheduling.AfterDurationEnd); + + private static BlockItemViewModel MakeItem(int id, int index) => + new( + id, + index, + CollectionType.SearchQuery, + null!, + null!, + null!, + null!, + "News", + "news", + PlaybackOrder.Shuffle, + IncludeInProgramGuide: true, + DisableWatermarks: false, + [], + []); + + private static BlockItemRequest MakeItemRequest() => + new( + CollectionType.SearchQuery, + CollectionId: null, + MultiCollectionId: null, + SmartCollectionId: null, + MediaItemId: null, + SearchTitle: "News", + SearchQuery: "news", + PlaybackOrder: PlaybackOrder.Shuffle, + IncludeInProgramGuide: true, + DisableWatermarks: false, + WatermarkIds: [], + GraphicsElementIds: []); + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(BlockController).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.Tests/Controllers/SearchControllerTests.cs b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs index f48878486..e7a7331ff 100644 --- a/ErsatzTV.Tests/Controllers/SearchControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs @@ -1,7 +1,11 @@ using System.Reflection; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Search; using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Api.Search; +using ErsatzTV.Core.Domain; using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; @@ -71,6 +75,53 @@ public class SearchControllerTests ok.Value.ShouldBe(results); } + [Test] + public async Task SearchCollections_Should_Map_To_Picker_Options() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + new(CollectionType.Collection, 3, "Movies", false, new MediaItemState()) + }); + + List result = + await _controller.SearchCollections("mov", CancellationToken.None); + + result.ShouldHaveSingleItem(); + result[0].Id.ShouldBe(3); + result[0].Name.ShouldBe("Movies"); + await _mediator.Received(1).Send( + Arg.Is(q => q.Query == "mov"), + Arg.Any()); + } + + [Test] + public async Task SearchTelevisionShows_Should_Map_MediaItemId_To_Option_Id() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(42, "The Show") }); + + List result = + await _controller.SearchTelevisionShows("show", CancellationToken.None); + + result.ShouldHaveSingleItem(); + result[0].Id.ShouldBe(42); + result[0].Name.ShouldBe("The Show"); + } + + [Test] + public async Task SearchSmartCollections_Should_Map_To_Picker_Options() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(9, "Recent", "query") }); + + List result = + await _controller.SearchSmartCollections("rec", CancellationToken.None); + + result.ShouldHaveSingleItem(); + result[0].Id.ShouldBe(9); + } + private static SearchResultsResponseModel EmptyResults() { var empty = new SearchResultGroupResponseModel(0, []); diff --git a/ErsatzTV/Controllers/Api/BlockController.cs b/ErsatzTV/Controllers/Api/BlockController.cs new file mode 100644 index 000000000..571461d7f --- /dev/null +++ b/ErsatzTV/Controllers/Api/BlockController.cs @@ -0,0 +1,255 @@ +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 BlockController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/blocks/groups", Name = "GetBlockGroups")] + [Tags("Blocks")] + [EndpointSummary("Get all block groups")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetGroups(CancellationToken cancellationToken) + { + List groups = await mediator.Send(new GetAllBlockGroups(), cancellationToken); + return groups.Map(ProjectToResponseModel).ToList(); + } + + [HttpPost("/api/blocks/groups")] + [Tags("Blocks")] + [EndpointSummary("Create a block group")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(BlockGroupResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task CreateGroup( + [Required] [FromBody] CreateBlockGroupRequest request, + CancellationToken cancellationToken) + { + Either result = + await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult( + vm => $"/api/blocks/groups/{vm.Id}", + vm => ProjectToResponseModel(vm)); + } + + [HttpDelete("/api/blocks/groups/{id:int}")] + [Tags("Blocks")] + [EndpointSummary("Delete a block group")] + [EndpointDescription( + "Deletes the block group. The database cascade removes every block (and its items) in the group.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task DeleteGroup(int id, CancellationToken cancellationToken) + { + List groups = await mediator.Send(new GetAllBlockGroups(), cancellationToken); + if (groups.All(g => g.Id != id)) + { + return ApiResults.NotFoundProblem(); + } + + Option result = await mediator.Send(new DeleteBlockGroup(id), cancellationToken); + return result.Match( + Some: error => error.ToErrorResult(), + None: () => new NoContentResult()); + } + + [HttpGet("/api/blocks")] + [Tags("Blocks")] + [EndpointSummary("Get all blocks")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) + { + List blocks = await mediator.Send(new GetAllBlocks(), cancellationToken); + return blocks.Map(ProjectToResponseModel).ToList(); + } + + [HttpGet("/api/blocks/{id:int}", Name = "GetBlockById")] + [Tags("Blocks")] + [EndpointSummary("Get a block by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(BlockResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetBlockById(id), cancellationToken); + return result.Map(ProjectToResponseModel).ToGetResult(); + } + + [HttpPost("/api/blocks")] + [Tags("Blocks")] + [EndpointSummary("Create a block")] + [EndpointDescription("Creates an empty block in the given block group. The block defaults to 30 minutes.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(BlockResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreateBlockRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult( + vm => $"/api/blocks/{vm.Id}", + vm => ProjectToResponseModel(vm)); + } + + [HttpDelete("/api/blocks/{id:int}")] + [Tags("Blocks")] + [EndpointSummary("Delete a block")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Option existing = await mediator.Send(new GetBlockById(id), cancellationToken); + if (existing.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Option result = await mediator.Send(new DeleteBlock(id), cancellationToken); + return result.Match( + Some: error => error.ToErrorResult(), + None: () => new NoContentResult()); + } + + [HttpGet("/api/blocks/{id:int}/items")] + [Tags("Blocks")] + [EndpointSummary("Get block items")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetItems(int id, CancellationToken cancellationToken) + { + Option block = await mediator.Send(new GetBlockById(id), cancellationToken); + if (block.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List items = await mediator.Send(new GetBlockItems(id), cancellationToken); + return new OkObjectResult(items.OrderBy(i => i.Index).Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/blocks/{id:int}")] + [Tags("Blocks")] + [EndpointSummary("Replace a block and its items")] + [EndpointDescription( + "Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned " + + "from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(BlockWithItemsResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Replace( + int id, + [Required] [FromBody] ReplaceBlockRequest request, + CancellationToken cancellationToken) + { + Option maybeBlock = await mediator.Send(new GetBlockById(id), cancellationToken); + if (maybeBlock.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + int groupId = maybeBlock.Map(b => b.GroupId).IfNone(0); + + Either result = + await mediator.Send(request.ToCommand(groupId, id), cancellationToken); + + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + Option refreshed = await mediator.Send(new GetBlockById(id), cancellationToken); + List items = await mediator.Send(new GetBlockItems(id), cancellationToken); + return refreshed.Match( + Some: vm => (IActionResult)new OkObjectResult( + ProjectToWithItemsResponseModel(vm, items)), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpPost("/api/blocks/{id:int}/preview")] + [Tags("Blocks")] + [EndpointSummary("Preview a block playout")] + [EndpointDescription( + "Builds a preview playout from the supplied block definition without persisting any changes to the " + + "block, its items, or any playout. Returns the resulting start/finish/title/duration list.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Preview( + int id, + [Required] [FromBody] ReplaceBlockRequest request, + CancellationToken cancellationToken) + { + Option maybeBlock = await mediator.Send(new GetBlockById(id), cancellationToken); + if (maybeBlock.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + int groupId = maybeBlock.Map(b => b.GroupId).IfNone(0); + + List preview = await mediator.Send( + new PreviewBlockPlayout(request.ToCommand(groupId, id)), + cancellationToken); + + return new OkObjectResult(preview.Map(ProjectToResponseModel).ToList()); + } + + private static BlockGroupResponseModel ProjectToResponseModel(BlockGroupViewModel vm) => + new(vm.Id, vm.Name); + + private static BlockResponseModel ProjectToResponseModel(BlockViewModel vm) => + new(vm.Id, vm.GroupId, vm.GroupName, vm.Name, vm.Minutes, vm.StopScheduling); + + private static BlockWithItemsResponseModel ProjectToWithItemsResponseModel( + BlockViewModel vm, + List items) => + new( + vm.Id, + vm.GroupId, + vm.GroupName, + vm.Name, + vm.Minutes, + vm.StopScheduling, + items.OrderBy(i => i.Index).Map(ProjectToResponseModel).ToList()); + + private static BlockItemResponseModel ProjectToResponseModel(BlockItemViewModel vm) => + new( + vm.Id, + vm.Index, + vm.CollectionType, + vm.Collection?.Id, + vm.Collection?.Name, + vm.MultiCollection?.Id, + vm.MultiCollection?.Name, + vm.SmartCollection?.Id, + vm.SmartCollection?.Name, + vm.MediaItem?.MediaItemId, + vm.MediaItem?.Name, + vm.SearchTitle, + vm.SearchQuery, + vm.PlaybackOrder, + vm.IncludeInProgramGuide, + vm.DisableWatermarks, + vm.Watermarks.Map(w => w.Id).ToList(), + vm.GraphicsElements.Map(g => g.Id).ToList()); + + private static BlockPreviewItemResponseModel ProjectToResponseModel(PlayoutItemPreviewViewModel vm) => + new(vm.Title, vm.Start, vm.Finish, vm.Duration); +} diff --git a/ErsatzTV/Controllers/Api/Requests/BlockItemRequest.cs b/ErsatzTV/Controllers/Api/Requests/BlockItemRequest.cs new file mode 100644 index 000000000..dfffa60c6 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/BlockItemRequest.cs @@ -0,0 +1,35 @@ +using ErsatzTV.Application.Scheduling; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record BlockItemRequest( + CollectionType CollectionType, + int? CollectionId, + int? MultiCollectionId, + int? SmartCollectionId, + int? MediaItemId, + string SearchTitle, + string SearchQuery, + PlaybackOrder PlaybackOrder, + bool IncludeInProgramGuide, + bool DisableWatermarks, + List WatermarkIds, + List GraphicsElementIds) +{ + public ReplaceBlockItem ToReplaceItem(int index) => + new( + index, + CollectionType, + CollectionId, + MultiCollectionId, + SmartCollectionId, + MediaItemId, + SearchTitle, + SearchQuery, + PlaybackOrder, + IncludeInProgramGuide, + DisableWatermarks, + WatermarkIds ?? [], + GraphicsElementIds ?? []); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateBlockGroupRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateBlockGroupRequest.cs new file mode 100644 index 000000000..0ae4d4d31 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateBlockGroupRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateBlockGroupRequest(string Name) +{ + public CreateBlockGroup ToCommand() => new(Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateBlockRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateBlockRequest.cs new file mode 100644 index 000000000..14741e90a --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateBlockRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateBlockRequest(int BlockGroupId, string Name) +{ + public CreateBlock ToCommand() => new(BlockGroupId, Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ReplaceBlockRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplaceBlockRequest.cs new file mode 100644 index 000000000..fe4e67622 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplaceBlockRequest.cs @@ -0,0 +1,20 @@ +using ErsatzTV.Application.Scheduling; +using ErsatzTV.Core.Domain.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ReplaceBlockRequest( + string Name, + int Minutes, + BlockStopScheduling StopScheduling, + List Items) +{ + public ReplaceBlockItems ToCommand(int blockGroupId, int blockId) => + new( + blockGroupId, + blockId, + Name, + Minutes, + StopScheduling, + (Items ?? []).Select((item, index) => item.ToReplaceItem(index)).ToList()); +} diff --git a/ErsatzTV/Controllers/Api/SearchController.cs b/ErsatzTV/Controllers/Api/SearchController.cs index 457a814e0..2fed2639a 100644 --- a/ErsatzTV/Controllers/Api/SearchController.cs +++ b/ErsatzTV/Controllers/Api/SearchController.cs @@ -1,5 +1,8 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Search; using ErsatzTV.Core; +using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Api.Search; using ErsatzTV.Extensions; using MediatR; @@ -35,4 +38,68 @@ public class SearchController(IMediator mediator) : ControllerBase cancellationToken); return new OkObjectResult(result); } + + [HttpGet("/api/search/collections", Name = "SearchCollections")] + [Tags("Search")] + [EndpointSummary("Search collections by name")] + [EndpointDescription("Returns matching collections as {id, name} options for scheduling editors.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> SearchCollections( + [FromQuery] string query = "", + CancellationToken cancellationToken = default) + { + List results = await mediator.Send( + new SearchCollections(query ?? string.Empty), + cancellationToken); + return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList(); + } + + [HttpGet("/api/search/television-shows", Name = "SearchTelevisionShows")] + [Tags("Search")] + [EndpointSummary("Search television shows by name")] + [EndpointDescription("Returns matching television shows as {id, name} options for scheduling editors.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> SearchTelevisionShows( + [FromQuery] string query = "", + CancellationToken cancellationToken = default) + { + List results = await mediator.Send( + new SearchTelevisionShows(query ?? string.Empty), + cancellationToken); + return results.Map(s => new SchedulingPickerOptionResponseModel(s.MediaItemId, s.Name)).ToList(); + } + + [HttpGet("/api/search/television-seasons", Name = "SearchTelevisionSeasons")] + [Tags("Search")] + [EndpointSummary("Search television seasons by name")] + [EndpointDescription("Returns matching television seasons as {id, name} options for scheduling editors.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> SearchTelevisionSeasons( + [FromQuery] string query = "", + CancellationToken cancellationToken = default) + { + List results = await mediator.Send( + new SearchTelevisionSeasons(query ?? string.Empty), + cancellationToken); + return results.Map(s => new SchedulingPickerOptionResponseModel(s.MediaItemId, s.Name)).ToList(); + } + + [HttpGet("/api/search/smart-collections", Name = "SearchSmartCollections")] + [Tags("Search")] + [EndpointSummary("Search smart collections by name")] + [EndpointDescription("Returns matching smart collections as {id, name} options for scheduling editors.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> SearchSmartCollections( + [FromQuery] string query = "", + CancellationToken cancellationToken = default) + { + List results = await mediator.Send( + new SearchSmartCollections(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 8170cd9ba..4dc0918e4 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -84,6 +84,692 @@ } } }, + "/api/blocks/groups": { + "get": { + "tags": [ + "Blocks" + ], + "summary": "Get all block groups", + "operationId": "GetBlockGroups", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockGroupResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockGroupResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockGroupResponseModel" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Blocks" + ], + "summary": "Create a block group", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateBlockGroupRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBlockGroupRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateBlockGroupRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateBlockGroupRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/BlockGroupResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlockGroupResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/BlockGroupResponseModel" + } + } + } + }, + "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/blocks/groups/{id}": { + "delete": { + "tags": [ + "Blocks" + ], + "summary": "Delete a block group", + "description": "Deletes the block group. The database cascade removes every block (and its items) in the group.", + "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/blocks": { + "get": { + "tags": [ + "Blocks" + ], + "summary": "Get all blocks", + "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" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Blocks" + ], + "summary": "Create a block", + "description": "Creates an empty block in the given block group. The block defaults to 30 minutes.", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateBlockRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBlockRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateBlockRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateBlockRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/BlockResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlockResponseModel" + } + }, + "text/json": { + "schema": { + "$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" + } + } + } + }, + "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/blocks/{id}": { + "get": { + "tags": [ + "Blocks" + ], + "summary": "Get a block by id", + "operationId": "GetBlockById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/BlockResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlockResponseModel" + } + }, + "text/json": { + "schema": { + "$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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Blocks" + ], + "summary": "Delete a block", + "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": [ + "Blocks" + ], + "summary": "Replace a block and its items", + "description": "Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceBlockRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceBlockRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceBlockRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceBlockRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/BlockWithItemsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlockWithItemsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/BlockWithItemsResponseModel" + } + } + } + }, + "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/blocks/{id}/items": { + "get": { + "tags": [ + "Blocks" + ], + "summary": "Get block items", + "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/BlockItemResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockItemResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockItemResponseModel" + } + } + } + } + }, + "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/blocks/{id}/preview": { + "post": { + "tags": [ + "Blocks" + ], + "summary": "Preview a block playout", + "description": "Builds a preview playout from the supplied block definition without persisting any changes to the block, its items, or any playout. Returns the resulting start/finish/title/duration list.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceBlockRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceBlockRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceBlockRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceBlockRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockPreviewItemResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockPreviewItemResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockPreviewItemResponseModel" + } + } + } + } + }, + "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/channels": { "get": { "tags": [ @@ -4748,6 +5434,210 @@ } } }, + "/api/search/collections": { + "get": { + "tags": [ + "Search" + ], + "summary": "Search collections by name", + "description": "Returns matching collections as {id, name} options for scheduling editors.", + "operationId": "SearchCollections", + "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/television-shows": { + "get": { + "tags": [ + "Search" + ], + "summary": "Search television shows by name", + "description": "Returns matching television shows as {id, name} options for scheduling editors.", + "operationId": "SearchTelevisionShows", + "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/television-seasons": { + "get": { + "tags": [ + "Search" + ], + "summary": "Search television seasons by name", + "description": "Returns matching television seasons as {id, name} options for scheduling editors.", + "operationId": "SearchTelevisionSeasons", + "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/smart-collections": { + "get": { + "tags": [ + "Search" + ], + "summary": "Search smart collections by name", + "description": "Returns matching smart collections as {id, name} options for scheduling editors.", + "operationId": "SearchSmartCollections", + "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": [ @@ -7431,6 +8321,337 @@ } } }, + "BlockGroupResponseModel": { + "required": [ + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + } + }, + "BlockItemRequest": { + "required": [ + "collectionType", + "collectionId", + "multiCollectionId", + "smartCollectionId", + "mediaItemId", + "searchTitle", + "searchQuery", + "playbackOrder", + "includeInProgramGuide", + "disableWatermarks", + "watermarkIds", + "graphicsElementIds" + ], + "type": "object", + "properties": { + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "searchTitle": { + "type": [ + "null", + "string" + ] + }, + "searchQuery": { + "type": [ + "null", + "string" + ] + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "includeInProgramGuide": { + "type": "boolean" + }, + "disableWatermarks": { + "type": "boolean" + }, + "watermarkIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "graphicsElementIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, + "BlockItemResponseModel": { + "required": [ + "id", + "index", + "collectionType", + "collectionId", + "collectionName", + "multiCollectionId", + "multiCollectionName", + "smartCollectionId", + "smartCollectionName", + "mediaItemId", + "mediaItemName", + "searchTitle", + "searchQuery", + "playbackOrder", + "includeInProgramGuide", + "disableWatermarks", + "watermarkIds", + "graphicsElementIds" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "index": { + "type": "integer", + "format": "int32" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "collectionName": { + "type": [ + "null", + "string" + ] + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionName": { + "type": [ + "null", + "string" + ] + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionName": { + "type": [ + "null", + "string" + ] + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemName": { + "type": [ + "null", + "string" + ] + }, + "searchTitle": { + "type": "string" + }, + "searchQuery": { + "type": "string" + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "includeInProgramGuide": { + "type": "boolean" + }, + "disableWatermarks": { + "type": "boolean" + }, + "watermarkIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "graphicsElementIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, + "BlockPreviewItemResponseModel": { + "required": [ + "title", + "start", + "finish", + "duration" + ], + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "start": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "finish": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "duration": { + "type": "string" + } + } + }, + "BlockResponseModel": { + "required": [ + "id", + "groupId", + "groupName", + "name", + "minutes", + "stopScheduling" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "groupId": { + "type": "integer", + "format": "int32" + }, + "groupName": { + "type": "string" + }, + "name": { + "type": "string" + }, + "minutes": { + "type": "integer", + "format": "int32" + }, + "stopScheduling": { + "$ref": "#/components/schemas/BlockStopScheduling" + } + } + }, + "BlockStopScheduling": { + "enum": [ + "AfterDurationEnd", + "BeforeDurationEnd" + ], + "type": "string" + }, + "BlockWithItemsResponseModel": { + "required": [ + "id", + "groupId", + "groupName", + "name", + "minutes", + "stopScheduling", + "items" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "groupId": { + "type": "integer", + "format": "int32" + }, + "groupName": { + "type": "string" + }, + "name": { + "type": "string" + }, + "minutes": { + "type": "integer", + "format": "int32" + }, + "stopScheduling": { + "$ref": "#/components/schemas/BlockStopScheduling" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockItemResponseModel" + } + } + } + }, "BulkDeleteChannelsRequest": { "required": [ "channelIds" @@ -8138,6 +9359,39 @@ } } }, + "CreateBlockGroupRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + } + } + }, + "CreateBlockRequest": { + "required": [ + "blockGroupId", + "name" + ], + "type": "object", + "properties": { + "blockGroupId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + } + } + }, "CreateChannelFromLineupAdvancedOptionsRequest": { "type": "object", "properties": { @@ -11102,6 +12356,39 @@ } } }, + "ReplaceBlockRequest": { + "required": [ + "name", + "minutes", + "stopScheduling", + "items" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "minutes": { + "type": "integer", + "format": "int32" + }, + "stopScheduling": { + "$ref": "#/components/schemas/BlockStopScheduling" + }, + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/BlockItemRequest" + } + } + } + }, "ReplaceScheduleItemsRequest": { "required": [ "items" @@ -11531,6 +12818,22 @@ } } }, + "SchedulingPickerOptionResponseModel": { + "required": [ + "id", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + } + }, "SearchResultGroupResponseModel": { "required": [ "totalCount", @@ -12998,6 +14301,9 @@ { "name": "Artwork" }, + { + "name": "Blocks" + }, { "name": "Channel" },