diff --git a/ErsatzTV.Core/Api/MediaCollections/MultiCollectionResponseModel.cs b/ErsatzTV.Core/Api/MediaCollections/MultiCollectionResponseModel.cs new file mode 100644 index 000000000..4117e95cc --- /dev/null +++ b/ErsatzTV.Core/Api/MediaCollections/MultiCollectionResponseModel.cs @@ -0,0 +1,20 @@ +#nullable enable +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaCollections; + +public record MultiCollectionResponseModel( + int Id, + string Name, + List Items); + +public record MultiCollectionItemResponseModel( + int? CollectionId, + int? SmartCollectionId, + string Name, + bool ScheduleAsGroup, + PlaybackOrder PlaybackOrder); + +public record PagedMultiCollectionsResponseModel( + int TotalCount, + List Page); diff --git a/ErsatzTV.Core/Api/MediaCollections/RerunCollectionResponseModel.cs b/ErsatzTV.Core/Api/MediaCollections/RerunCollectionResponseModel.cs new file mode 100644 index 000000000..68383486a --- /dev/null +++ b/ErsatzTV.Core/Api/MediaCollections/RerunCollectionResponseModel.cs @@ -0,0 +1,17 @@ +#nullable enable +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaCollections; + +public record RerunCollectionResponseModel( + int Id, + string Name, + CollectionType CollectionType, + int? SelectedId, + string? SelectedName, + PlaybackOrder FirstRunPlaybackOrder, + PlaybackOrder RerunPlaybackOrder); + +public record PagedRerunCollectionsResponseModel( + int TotalCount, + List Page); diff --git a/ErsatzTV.Tests/Controllers/MultiCollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/MultiCollectionControllerTests.cs new file mode 100644 index 000000000..d40215341 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/MultiCollectionControllerTests.cs @@ -0,0 +1,220 @@ +using System.Reflection; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaCollections; +using ErsatzTV.Core.Domain; +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 MultiCollectionControllerTests +{ + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new MultiCollectionController(_mediator); + } + + private MultiCollectionController _controller = null!; + private IMediator _mediator = null!; + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(MultiCollectionController.GetAll), "GET", "/api/multi-collections"); + ShouldHaveActionRoute(nameof(MultiCollectionController.GetById), "GET", "/api/multi-collections/{id:int}"); + ShouldHaveActionRoute(nameof(MultiCollectionController.Create), "POST", "/api/multi-collections"); + ShouldHaveActionRoute(nameof(MultiCollectionController.Update), "PUT", "/api/multi-collections/{id:int}"); + ShouldHaveActionRoute(nameof(MultiCollectionController.Delete), "DELETE", "/api/multi-collections/{id:int}"); + } + + [Test] + public async Task GetAll_Should_Clamp_Paging_And_Map_Response() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedMultiCollectionsViewModel(1, new List { MakeMulti(3, "Kids") })); + + PagedMultiCollectionsResponseModel result = + await _controller.GetAll("k", -5, 9999, CancellationToken.None); + + result.TotalCount.ShouldBe(1); + result.Page.Count.ShouldBe(1); + result.Page[0].Id.ShouldBe(3); + result.Page[0].Items.Count.ShouldBe(2); + result.Page[0].Items[0].CollectionId.ShouldBe(10); + result.Page[0].Items[1].SmartCollectionId.ShouldBe(20); + await _mediator.Received(1).Send( + Arg.Is(q => q.Query == "k" && q.PageNum == 0 && q.PageSize == 100), + Arg.Any()); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeMulti(4, "Kids"))); + + 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 Create_Should_Return_201_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeMulti(8, "Kids"))); + + IActionResult result = await _controller.Create( + new CreateMultiCollectionRequest( + "Kids", + new List + { + new(10, null, true, PlaybackOrder.Chronological), + new(null, 20, false, PlaybackOrder.Shuffle) + }), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/multi-collections/8"); + created.Value.ShouldBeOfType().Name.ShouldBe("Kids"); + await _mediator.Received(1).Send( + Arg.Is(c => + c.Name == "Kids" && + c.Items.Count == 2 && + c.Items[0].CollectionId == 10 && + c.Items[0].ScheduleAsGroup && + c.Items[0].PlaybackOrder == PlaybackOrder.Chronological && + c.Items[1].SmartCollectionId == 20), + 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 CreateMultiCollectionRequest("Kids", new List()), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_200_With_Refreshed_Body_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeMulti(4, "Kids"))); + + IActionResult result = await _controller.Update( + 4, + new UpdateMultiCollectionRequest( + "Kids", + new List { new(10, null, true, PlaybackOrder.Random) }), + CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Id.ShouldBe(4); + await _mediator.Received(1).Send( + Arg.Is(c => + c.MultiCollectionId == 4 && + c.Name == "Kids" && + c.Items.Count == 1 && + c.Items[0].CollectionId == 10 && + c.Items[0].PlaybackOrder == PlaybackOrder.Random), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Update( + 4, + new UpdateMultiCollectionRequest("Kids", new List()), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.MultiCollectionId == 4), + Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_404_On_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new ErsatzTV.Core.Errors.NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static MultiCollectionViewModel MakeMulti(int id, string name) => + new( + id, + name, + new List + { + new( + id, + new MediaCollectionViewModel(CollectionType.Collection, 10, "Movies", false, MediaItemState.Normal), + true, + PlaybackOrder.Chronological) + }, + new List + { + new(id, new SmartCollectionViewModel(20, "Smart", "query"), false, PlaybackOrder.Shuffle) + }); + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(MultiCollectionController).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/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index a37e4f746..3f2a84ed1 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -144,6 +144,20 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/smart-collections/{id}", "put", "422")] [TestCase("/api/smart-collections/{id}", "delete", "404")] [TestCase("/api/smart-collections/{id}", "delete", "422")] + [TestCase("/api/multi-collections/{id}", "get", "404")] + [TestCase("/api/multi-collections", "post", "404")] + [TestCase("/api/multi-collections", "post", "422")] + [TestCase("/api/multi-collections/{id}", "put", "404")] + [TestCase("/api/multi-collections/{id}", "put", "422")] + [TestCase("/api/multi-collections/{id}", "delete", "404")] + [TestCase("/api/multi-collections/{id}", "delete", "422")] + [TestCase("/api/rerun-collections/{id}", "get", "404")] + [TestCase("/api/rerun-collections", "post", "404")] + [TestCase("/api/rerun-collections", "post", "422")] + [TestCase("/api/rerun-collections/{id}", "put", "404")] + [TestCase("/api/rerun-collections/{id}", "put", "422")] + [TestCase("/api/rerun-collections/{id}", "delete", "404")] + [TestCase("/api/rerun-collections/{id}", "delete", "422")] [TestCase("/api/schedules/{id}", "get", "404")] [TestCase("/api/schedules", "post", "404")] [TestCase("/api/schedules", "post", "422")] diff --git a/ErsatzTV.Tests/Controllers/RerunCollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/RerunCollectionControllerTests.cs new file mode 100644 index 000000000..9a6430775 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/RerunCollectionControllerTests.cs @@ -0,0 +1,288 @@ +using System.Reflection; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaCollections; +using ErsatzTV.Core.Domain; +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 RerunCollectionControllerTests +{ + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new RerunCollectionController(_mediator); + } + + private RerunCollectionController _controller = null!; + private IMediator _mediator = null!; + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(RerunCollectionController.GetAll), "GET", "/api/rerun-collections"); + ShouldHaveActionRoute(nameof(RerunCollectionController.GetById), "GET", "/api/rerun-collections/{id:int}"); + ShouldHaveActionRoute(nameof(RerunCollectionController.Create), "POST", "/api/rerun-collections"); + ShouldHaveActionRoute(nameof(RerunCollectionController.Update), "PUT", "/api/rerun-collections/{id:int}"); + ShouldHaveActionRoute(nameof(RerunCollectionController.Delete), "DELETE", "/api/rerun-collections/{id:int}"); + } + + [Test] + public async Task GetAll_Should_Clamp_Paging_And_Map_Response() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedRerunCollectionsViewModel( + 1, + new List { MakeRerunFromSmart(3, "Nightly") })); + + PagedRerunCollectionsResponseModel result = + await _controller.GetAll("n", -5, 9999, CancellationToken.None); + + result.TotalCount.ShouldBe(1); + result.Page.Count.ShouldBe(1); + result.Page[0].Id.ShouldBe(3); + result.Page[0].CollectionType.ShouldBe(CollectionType.SmartCollection); + result.Page[0].SelectedId.ShouldBe(20); + result.Page[0].SelectedName.ShouldBe("Smart"); + await _mediator.Received(1).Send( + Arg.Is(q => q.Query == "n" && q.PageNum == 0 && q.PageSize == 100), + Arg.Any()); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeRerunFromSmart(4, "Nightly"))); + + 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 Create_Should_Return_201_And_Resolve_Selected_Id_Into_Collection_Vm() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeRerunFromSmart(8, "Nightly"))); + + IActionResult result = await _controller.Create( + new CreateRerunCollectionRequest( + "Nightly", + CollectionType.Collection, + 42, + PlaybackOrder.Chronological, + PlaybackOrder.Shuffle), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/rerun-collections/8"); + await _mediator.Received(1).Send( + Arg.Is(c => + c.Name == "Nightly" && + c.CollectionType == CollectionType.Collection && + c.Collection != null && c.Collection.Id == 42 && + c.MultiCollection == null && + c.SmartCollection == null && + c.MediaItem == null && + c.FirstRunPlaybackOrder == PlaybackOrder.Chronological && + c.RerunPlaybackOrder == PlaybackOrder.Shuffle), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Resolve_Media_Item_Selection_For_Show_Type() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeRerunFromSmart(9, "Nightly"))); + + await _controller.Create( + new CreateRerunCollectionRequest( + "Nightly", + CollectionType.TelevisionShow, + 77, + PlaybackOrder.Chronological, + PlaybackOrder.Chronological), + CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => + c.CollectionType == CollectionType.TelevisionShow && + c.Collection == null && + c.MediaItem != null && c.MediaItem.MediaItemId == 77), + 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 CreateRerunCollectionRequest( + "Nightly", + CollectionType.SmartCollection, + 1, + PlaybackOrder.Chronological, + PlaybackOrder.Chronological), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Create_Should_Return_422_For_Unsupported_Collection_Type() + { + IActionResult result = await _controller.Create( + new CreateRerunCollectionRequest( + "Nightly", + CollectionType.Playlist, + 1, + PlaybackOrder.Chronological, + PlaybackOrder.Chronological), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_200_With_Refreshed_Body_And_Resolve_Selection() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeRerunFromSmart(4, "Nightly"))); + + IActionResult result = await _controller.Update( + 4, + new UpdateRerunCollectionRequest( + "Nightly", + CollectionType.MultiCollection, + 55, + PlaybackOrder.Chronological, + PlaybackOrder.Shuffle), + CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Id.ShouldBe(4); + await _mediator.Received(1).Send( + Arg.Is(c => + c.RerunCollectionId == 4 && + c.Name == "Nightly" && + c.CollectionType == CollectionType.MultiCollection && + c.MultiCollection != null && c.MultiCollection.Id == 55 && + c.Collection == null && + c.SmartCollection == null && + c.MediaItem == null), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Update( + 4, + new UpdateRerunCollectionRequest( + "Nightly", + CollectionType.SmartCollection, + 1, + PlaybackOrder.Chronological, + PlaybackOrder.Chronological), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_422_For_Unsupported_Collection_Type() + { + IActionResult result = await _controller.Update( + 4, + new UpdateRerunCollectionRequest( + "Nightly", + CollectionType.RerunFirstRun, + 1, + PlaybackOrder.Chronological, + PlaybackOrder.Chronological), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.RerunCollectionId == 4), + Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_404_On_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new ErsatzTV.Core.Errors.NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static RerunCollectionViewModel MakeRerunFromSmart(int id, string name) => + new( + id, + name, + CollectionType.SmartCollection, + null, + null, + new SmartCollectionViewModel(20, "Smart", "query"), + null, + PlaybackOrder.Chronological, + PlaybackOrder.Shuffle); + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(RerunCollectionController).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/MultiCollectionController.cs b/ErsatzTV/Controllers/Api/MultiCollectionController.cs new file mode 100644 index 000000000..6fa0b2e6e --- /dev/null +++ b/ErsatzTV/Controllers/Api/MultiCollectionController.cs @@ -0,0 +1,129 @@ +using System.ComponentModel.DataAnnotations; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaCollections; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class MultiCollectionController(IMediator mediator) : ControllerBase +{ + private const int MaxPageSize = 100; + + [HttpGet("/api/multi-collections", Name = "GetMultiCollections")] + [Tags("Multi Collections")] + [EndpointSummary("Get all multi collections (paged)")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedMultiCollectionsResponseModel), StatusCodes.Status200OK)] + public async Task GetAll( + [FromQuery] string query = "", + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + int clampedPageNum = Math.Max(0, pageNum); + int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); + + PagedMultiCollectionsViewModel result = await mediator.Send( + new GetPagedMultiCollections(query ?? string.Empty, clampedPageNum, clampedPageSize), + cancellationToken); + + return new PagedMultiCollectionsResponseModel( + result.TotalCount, + result.Page.Map(ProjectToResponseModel).ToList()); + } + + [HttpGet("/api/multi-collections/{id:int}", Name = "GetMultiCollectionById")] + [Tags("Multi Collections")] + [EndpointSummary("Get a multi collection by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = + await mediator.Send(new GetMultiCollectionById(id), cancellationToken); + return result.Map(ProjectToResponseModel).ToGetResult(); + } + + [HttpPost("/api/multi-collections")] + [Tags("Multi Collections")] + [EndpointSummary("Create a multi collection")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreateMultiCollectionRequest request, + CancellationToken cancellationToken) + { + Either result = + await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult( + vm => $"/api/multi-collections/{vm.Id}", + ProjectToResponseModel); + } + + [HttpPut("/api/multi-collections/{id:int}")] + [Tags("Multi Collections")] + [EndpointSummary("Update a multi collection")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Update( + int id, + [Required] [FromBody] UpdateMultiCollectionRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(id), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + Option multiCollection = + await mediator.Send(new GetMultiCollectionById(id), cancellationToken); + return multiCollection.Match( + Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpDelete("/api/multi-collections/{id:int}")] + [Tags("Multi Collections")] + [EndpointSummary("Delete a multi collection")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteMultiCollection(id), cancellationToken); + return result.ToDeletedResult(); + } + + private static MultiCollectionResponseModel ProjectToResponseModel(MultiCollectionViewModel vm) + { + List items = vm.Items + .Map(i => new MultiCollectionItemResponseModel( + i.Collection.Id, + null, + i.Collection.Name, + i.ScheduleAsGroup, + i.PlaybackOrder)) + .Concat(vm.SmartItems.Map(i => new MultiCollectionItemResponseModel( + null, + i.SmartCollection.Id, + i.SmartCollection.Name, + i.ScheduleAsGroup, + i.PlaybackOrder))) + .ToList(); + + return new MultiCollectionResponseModel(vm.Id, vm.Name, items); + } +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateMultiCollectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateMultiCollectionRequest.cs new file mode 100644 index 000000000..5d655fd2d --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateMultiCollectionRequest.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record MultiCollectionItemRequest( + int? CollectionId, + int? SmartCollectionId, + bool ScheduleAsGroup, + PlaybackOrder PlaybackOrder); + +public record CreateMultiCollectionRequest(string Name, List Items) +{ + public CreateMultiCollection ToCommand() => + new( + Name, + (Items ?? new List()) + .Select(i => new CreateMultiCollectionItem( + i.CollectionId, + i.SmartCollectionId, + i.ScheduleAsGroup, + i.PlaybackOrder)) + .ToList()); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateRerunCollectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateRerunCollectionRequest.cs new file mode 100644 index 000000000..8db18a0e7 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateRerunCollectionRequest.cs @@ -0,0 +1,32 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateRerunCollectionRequest( + string Name, + CollectionType CollectionType, + int SelectedId, + PlaybackOrder FirstRunPlaybackOrder, + PlaybackOrder RerunPlaybackOrder) +{ + public CreateRerunCollection ToCommand() + { + (MediaCollectionViewModel collection, + MultiCollectionViewModel multiCollection, + SmartCollectionViewModel smartCollection, + NamedMediaItemViewModel mediaItem) = + RerunCollectionRequestMapping.ResolveSelection(CollectionType, SelectedId); + + return new CreateRerunCollection( + Name, + CollectionType, + collection, + multiCollection, + smartCollection, + mediaItem, + FirstRunPlaybackOrder, + RerunPlaybackOrder); + } +} diff --git a/ErsatzTV/Controllers/Api/Requests/RerunCollectionRequestMapping.cs b/ErsatzTV/Controllers/Api/Requests/RerunCollectionRequestMapping.cs new file mode 100644 index 000000000..61899bca8 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/RerunCollectionRequestMapping.cs @@ -0,0 +1,76 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Controllers.Api.Requests; + +// A rerun collection is a tagged union: CollectionType selects which of Collection / +// MultiCollection / SmartCollection / MediaItem is populated. The Create/Update handlers +// read ONLY the `.Id` (or `.MediaItemId`) off the chosen VM — verified in +// CreateRerunCollectionHandler.Validate / UpdateRerunCollectionHandler.ApplyUpdateRequest, +// both of which do `request.Collection?.Id`, `request.MultiCollection?.Id`, +// `request.SmartCollection?.Id`, `request.MediaItem?.MediaItemId` and nothing else. So we +// construct a minimal VM carrying just the selected id (defaults elsewhere) rather than +// doing extra lookup queries. The remaining VMs are left null (the handler expects that). +internal static class RerunCollectionRequestMapping +{ + /// + /// The only values valid as a rerun collection's selected + /// source. Excludes Playlist / RerunFirstRun / RerunRerun / SearchQuery / Fake* — those + /// are not media-item-backed collection types and must not be resolved via + /// . Callers (the controller) must check this first. + /// + public static bool IsSupportedSelectionType(CollectionType collectionType) => + collectionType is CollectionType.Collection + or CollectionType.MultiCollection + or CollectionType.SmartCollection + or CollectionType.TelevisionShow + or CollectionType.TelevisionSeason + or CollectionType.Artist + or CollectionType.Movie + or CollectionType.Episode + or CollectionType.MusicVideo + or CollectionType.OtherVideo + or CollectionType.Song + or CollectionType.Image + or CollectionType.RemoteStream; + + public static (MediaCollectionViewModel Collection, + MultiCollectionViewModel MultiCollection, + SmartCollectionViewModel SmartCollection, + NamedMediaItemViewModel MediaItem) ResolveSelection(CollectionType collectionType, int selectedId) => + collectionType switch + { + CollectionType.Collection => ( + new MediaCollectionViewModel( + CollectionType.Collection, + selectedId, + string.Empty, + false, + MediaItemState.Normal), + null, + null, + null), + CollectionType.MultiCollection => ( + null, + new MultiCollectionViewModel(selectedId, string.Empty, [], []), + null, + null), + CollectionType.SmartCollection => ( + null, + null, + new SmartCollectionViewModel(selectedId, string.Empty, string.Empty), + null), + + // Any media-item-backed collection type (TelevisionShow, TelevisionSeason, + // Artist, Movie, Episode, MusicVideo, OtherVideo, Song, Image, ...) resolves to + // the MediaItem VM keyed by media item id. The caller (RerunCollectionController) + // has already validated CollectionType via IsSupportedSelectionType, so this + // catch-all only ever receives a real media-item type. + _ => ( + null, + null, + null, + new NamedMediaItemViewModel(selectedId, string.Empty)) + }; +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateMultiCollectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateMultiCollectionRequest.cs new file mode 100644 index 000000000..07bb99d3c --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateMultiCollectionRequest.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateMultiCollectionRequest(string Name, List Items) +{ + public UpdateMultiCollection ToCommand(int id) => + new( + id, + Name, + (Items ?? new List()) + .Select(i => new UpdateMultiCollectionItem( + i.CollectionId, + i.SmartCollectionId, + i.ScheduleAsGroup, + i.PlaybackOrder)) + .ToList()); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateRerunCollectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateRerunCollectionRequest.cs new file mode 100644 index 000000000..73cd91328 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateRerunCollectionRequest.cs @@ -0,0 +1,33 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateRerunCollectionRequest( + string Name, + CollectionType CollectionType, + int SelectedId, + PlaybackOrder FirstRunPlaybackOrder, + PlaybackOrder RerunPlaybackOrder) +{ + public UpdateRerunCollection ToCommand(int id) + { + (MediaCollectionViewModel collection, + MultiCollectionViewModel multiCollection, + SmartCollectionViewModel smartCollection, + NamedMediaItemViewModel mediaItem) = + RerunCollectionRequestMapping.ResolveSelection(CollectionType, SelectedId); + + return new UpdateRerunCollection( + id, + Name, + CollectionType, + collection, + multiCollection, + smartCollection, + mediaItem, + FirstRunPlaybackOrder, + RerunPlaybackOrder); + } +} diff --git a/ErsatzTV/Controllers/Api/RerunCollectionController.cs b/ErsatzTV/Controllers/Api/RerunCollectionController.cs new file mode 100644 index 000000000..6faa54031 --- /dev/null +++ b/ErsatzTV/Controllers/Api/RerunCollectionController.cs @@ -0,0 +1,148 @@ +using System.ComponentModel.DataAnnotations; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaCollections; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class RerunCollectionController(IMediator mediator) : ControllerBase +{ + private const int MaxPageSize = 100; + + [HttpGet("/api/rerun-collections", Name = "GetRerunCollections")] + [Tags("Rerun Collections")] + [EndpointSummary("Get all rerun collections (paged)")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedRerunCollectionsResponseModel), StatusCodes.Status200OK)] + public async Task GetAll( + [FromQuery] string query = "", + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + int clampedPageNum = Math.Max(0, pageNum); + int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); + + PagedRerunCollectionsViewModel result = await mediator.Send( + new GetPagedRerunCollections(query ?? string.Empty, clampedPageNum, clampedPageSize), + cancellationToken); + + return new PagedRerunCollectionsResponseModel( + result.TotalCount, + result.Page.Map(ProjectToResponseModel).ToList()); + } + + [HttpGet("/api/rerun-collections/{id:int}", Name = "GetRerunCollectionById")] + [Tags("Rerun Collections")] + [EndpointSummary("Get a rerun collection by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = + await mediator.Send(new GetRerunCollectionById(id), cancellationToken); + return result.Map(ProjectToResponseModel).ToGetResult(); + } + + [HttpPost("/api/rerun-collections")] + [Tags("Rerun Collections")] + [EndpointSummary("Create a rerun collection")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreateRerunCollectionRequest request, + CancellationToken cancellationToken) + { + if (!RerunCollectionRequestMapping.IsSupportedSelectionType(request.CollectionType)) + { + return BaseError.New( + $"Unsupported collection type '{request.CollectionType}' for a rerun collection") + .ToErrorResult(); + } + + Either result = + await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult( + vm => $"/api/rerun-collections/{vm.Id}", + ProjectToResponseModel); + } + + [HttpPut("/api/rerun-collections/{id:int}")] + [Tags("Rerun Collections")] + [EndpointSummary("Update a rerun collection")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Update( + int id, + [Required] [FromBody] UpdateRerunCollectionRequest request, + CancellationToken cancellationToken) + { + if (!RerunCollectionRequestMapping.IsSupportedSelectionType(request.CollectionType)) + { + return BaseError.New( + $"Unsupported collection type '{request.CollectionType}' for a rerun collection") + .ToErrorResult(); + } + + Either result = await mediator.Send(request.ToCommand(id), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + Option rerunCollection = + await mediator.Send(new GetRerunCollectionById(id), cancellationToken); + return rerunCollection.Match( + Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpDelete("/api/rerun-collections/{id:int}")] + [Tags("Rerun Collections")] + [EndpointSummary("Delete a rerun collection")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteRerunCollection(id), cancellationToken); + return result.ToDeletedResult(); + } + + // A rerun collection is a tagged union keyed by CollectionType; exactly one of + // Collection / MultiCollection / SmartCollection / MediaItem is populated on the VM. + // Flatten it to a single selected id + name for the SPA. + private static RerunCollectionResponseModel ProjectToResponseModel(RerunCollectionViewModel vm) + { + int? selectedId = vm.Collection?.Id + ?? vm.MultiCollection?.Id + ?? vm.SmartCollection?.Id + ?? vm.MediaItem?.MediaItemId; + + string selectedName = vm.Collection?.Name + ?? vm.MultiCollection?.Name + ?? vm.SmartCollection?.Name + ?? vm.MediaItem?.Name; + + return new RerunCollectionResponseModel( + vm.Id, + vm.Name, + vm.CollectionType, + selectedId, + selectedName, + vm.FirstRunPlaybackOrder, + vm.RerunPlaybackOrder); + } +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 054b42379..bc4a96ffb 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -5655,6 +5655,386 @@ } } }, + "/api/multi-collections": { + "get": { + "tags": [ + "Multi Collections" + ], + "summary": "Get all multi collections (paged)", + "operationId": "GetMultiCollections", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedMultiCollectionsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedMultiCollectionsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedMultiCollectionsResponseModel" + } + } + } + } + } + }, + "post": { + "tags": [ + "Multi Collections" + ], + "summary": "Create a multi collection", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateMultiCollectionRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMultiCollectionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateMultiCollectionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateMultiCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + } + } + }, + "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/multi-collections/{id}": { + "get": { + "tags": [ + "Multi Collections" + ], + "summary": "Get a multi collection by id", + "operationId": "GetMultiCollectionById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + } + } + }, + "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": [ + "Multi Collections" + ], + "summary": "Update a multi collection", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateMultiCollectionRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMultiCollectionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMultiCollectionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateMultiCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Multi Collections" + ], + "summary": "Delete a multi collection", + "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" + } + } + } + }, + "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/playlists/groups": { "get": { "tags": [ @@ -7028,6 +7408,386 @@ } } }, + "/api/rerun-collections": { + "get": { + "tags": [ + "Rerun Collections" + ], + "summary": "Get all rerun collections (paged)", + "operationId": "GetRerunCollections", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedRerunCollectionsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedRerunCollectionsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedRerunCollectionsResponseModel" + } + } + } + } + } + }, + "post": { + "tags": [ + "Rerun Collections" + ], + "summary": "Create a rerun collection", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateRerunCollectionRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRerunCollectionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateRerunCollectionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateRerunCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + } + } + }, + "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/rerun-collections/{id}": { + "get": { + "tags": [ + "Rerun Collections" + ], + "summary": "Get a rerun collection by id", + "operationId": "GetRerunCollectionById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + } + } + }, + "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": [ + "Rerun Collections" + ], + "summary": "Update a rerun collection", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateRerunCollectionRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRerunCollectionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRerunCollectionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateRerunCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Rerun Collections" + ], + "summary": "Delete a rerun collection", + "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" + } + } + } + }, + "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/resolution/by-name/{name}": { "get": { "tags": [ @@ -14165,6 +14925,30 @@ } } }, + "CreateMultiCollectionRequest": { + "required": [ + "name", + "items" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/MultiCollectionItemRequest" + } + } + } + }, "CreatePlayoutRequest": { "required": [ "channelId", @@ -14196,6 +14980,37 @@ } } }, + "CreateRerunCollectionRequest": { + "required": [ + "name", + "collectionType", + "selectedId", + "firstRunPlaybackOrder", + "rerunPlaybackOrder" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "selectedId": { + "type": "integer", + "format": "int32" + }, + "firstRunPlaybackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "rerunPlaybackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + } + } + }, "CreateResolutionRequest": { "required": [ "width", @@ -16404,6 +17219,72 @@ } } }, + "MultiCollectionItemRequest": { + "required": [ + "collectionId", + "smartCollectionId", + "scheduleAsGroup", + "playbackOrder" + ], + "type": "object", + "properties": { + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "scheduleAsGroup": { + "type": "boolean" + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + } + } + }, + "MultiCollectionItemResponseModel": { + "required": [ + "collectionId", + "smartCollectionId", + "name", + "scheduleAsGroup", + "playbackOrder" + ], + "type": "object", + "properties": { + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "name": { + "type": "string" + }, + "scheduleAsGroup": { + "type": "boolean" + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + } + } + }, "MultiCollectionItemViewModel": { "required": [ "multiCollectionId", @@ -16428,6 +17309,29 @@ } } }, + "MultiCollectionResponseModel": { + "required": [ + "id", + "name", + "items" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MultiCollectionItemResponseModel" + } + } + } + }, "MultiCollectionSmartItemViewModel": { "required": [ "multiCollectionId", @@ -16576,6 +17480,25 @@ } } }, + "PagedMultiCollectionsResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MultiCollectionResponseModel" + } + } + } + }, "PagedPlayoutHistoryResponseModel": { "required": [ "totalCount", @@ -16639,6 +17562,25 @@ } } }, + "PagedRerunCollectionsResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RerunCollectionResponseModel" + } + } + } + }, "PagedTraktListsResponseModel": { "required": [ "totalCount", @@ -17983,6 +18925,49 @@ } } }, + "RerunCollectionResponseModel": { + "required": [ + "id", + "name", + "collectionType", + "selectedId", + "selectedName", + "firstRunPlaybackOrder", + "rerunPlaybackOrder" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "selectedId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "selectedName": { + "type": [ + "null", + "string" + ] + }, + "firstRunPlaybackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "rerunPlaybackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + } + } + }, "RerunCollectionViewModel": { "required": [ "id", @@ -19685,6 +20670,30 @@ } } }, + "UpdateMultiCollectionRequest": { + "required": [ + "name", + "items" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/MultiCollectionItemRequest" + } + } + } + }, "UpdatePlayoutDetailsRequest": { "required": [ "dailyRebuildTime", @@ -19728,6 +20737,37 @@ } } }, + "UpdateRerunCollectionRequest": { + "required": [ + "name", + "collectionType", + "selectedId", + "firstRunPlaybackOrder", + "rerunPlaybackOrder" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "selectedId": { + "type": "integer", + "format": "int32" + }, + "firstRunPlaybackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "rerunPlaybackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + } + } + }, "UpdateScannerSettingsRequest": { "required": [ "libraryRefreshInterval" @@ -20307,12 +21347,18 @@ { "name": "Movies" }, + { + "name": "Multi Collections" + }, { "name": "Playlists" }, { "name": "Playouts" }, + { + "name": "Rerun Collections" + }, { "name": "Resolution" }, diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index cea456395..9e1b0368e 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -104,8 +104,8 @@ been added to the redirect map yet. | Blazor route | File | Blocking issue | |---|---|---| -| `/media/multi-collections`(`/add`, `/{Id}/edit`) | `MultiCollections.razor`, `MultiCollectionEditor.razor` | #151 (multi-collection management API) | -| `/media/rerun-collections`(`/add`, `/{Id}/edit`) | `RerunCollections.razor`, `RerunCollectionEditor.razor` | #152 (rerun-collection management API) | +| `/media/multi-collections`(`/add`, `/{Id}/edit`) | `MultiCollections.razor`, `MultiCollectionEditor.razor` | #151 — **REST API DONE** (`MultiCollectionController`, `/api/multi-collections` CRUD + `web/src/api/multiCollections.ts`); SPA editor screen still pending (follow-up) | +| `/media/rerun-collections`(`/add`, `/{Id}/edit`) | `RerunCollections.razor`, `RerunCollectionEditor.razor` | #152 — **REST API DONE** (`RerunCollectionController`, `/api/rerun-collections` CRUD + `web/src/api/rerunCollections.ts`); SPA editor screen still pending (follow-up) | | `/media/playlists`(`/{Id}`) editing depth beyond what `/app/collections` covers | `Playlists.razor`, `PlaylistEditor.razor` | #153 (playlist variant management API) | **#155 RESOLVED** (collection-items enumeration): `GET /api/collections/{id}/items` (paged) now returns a diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index dc4b42363..6659ff8f2 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -114 endpoints, 176 operations. +118 endpoints, 186 operations. ## Artists @@ -188,6 +188,16 @@ |---|---|---|---| | GET | `/api/movies/{id}` | GetMovieById | Get a movie by id | +## Multi Collections + +| Method | Path | Operation | Summary | +|---|---|---|---| +| GET | `/api/multi-collections` | GetMultiCollections | Get all multi collections (paged) | +| POST | `/api/multi-collections` | | Create a multi collection | +| DELETE | `/api/multi-collections/{id}` | | Delete a multi collection | +| GET | `/api/multi-collections/{id}` | GetMultiCollectionById | Get a multi collection by id | +| PUT | `/api/multi-collections/{id}` | | Update a multi collection | + ## Playlists | Method | Path | Operation | Summary | @@ -216,6 +226,16 @@ | GET | `/api/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates | | PUT | `/api/playouts/{id}/templates` | | Replace a block playout's templates | +## Rerun Collections + +| Method | Path | Operation | Summary | +|---|---|---|---| +| GET | `/api/rerun-collections` | GetRerunCollections | Get all rerun collections (paged) | +| POST | `/api/rerun-collections` | | Create a rerun collection | +| DELETE | `/api/rerun-collections/{id}` | | Delete a rerun collection | +| GET | `/api/rerun-collections/{id}` | GetRerunCollectionById | Get a rerun collection by id | +| PUT | `/api/rerun-collections/{id}` | | Update a rerun collection | + ## Resolution | Method | Path | Operation | Summary | diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 3e4c9f59d..d2ec8688b 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -432,12 +432,23 @@ export interface components { "playlistId": null | number; "expression": null | string; "useChaptersAsMediaItems": boolean; + }; + "CreateMultiCollectionRequest": { + "name": null | string; + "items": null | Array; }; "CreatePlayoutRequest": { "channelId": number; "scheduleKind": components["schemas"]["PlayoutScheduleKind"]; "programScheduleId": null | number; "scheduleFile": null | string; + }; + "CreateRerunCollectionRequest": { + "name": null | string; + "collectionType": components["schemas"]["CollectionType"]; + "selectedId": number; + "firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"]; + "rerunPlaybackOrder": components["schemas"]["PlaybackOrder"]; }; "CreateResolutionRequest": { "width": number; @@ -846,12 +857,30 @@ export interface components { "state": components["schemas"]["MediaItemState"]; "poster": string; "fanArt": string; + }; + "MultiCollectionItemRequest": { + "collectionId": null | number; + "smartCollectionId": null | number; + "scheduleAsGroup": boolean; + "playbackOrder": components["schemas"]["PlaybackOrder"]; + }; + "MultiCollectionItemResponseModel": { + "collectionId": null | number; + "smartCollectionId": null | number; + "name": string; + "scheduleAsGroup": boolean; + "playbackOrder": components["schemas"]["PlaybackOrder"]; }; "MultiCollectionItemViewModel": { "multiCollectionId": number; "collection": components["schemas"]["MediaCollectionViewModel"]; "scheduleAsGroup": boolean; "playbackOrder": components["schemas"]["PlaybackOrder"]; + }; + "MultiCollectionResponseModel": { + "id": number; + "name": string; + "items": Array; }; "MultiCollectionSmartItemViewModel": { "multiCollectionId": number; @@ -879,6 +908,10 @@ export interface components { "PagedLogEntriesResponseModel": { "totalCount": number; "page": Array; + }; + "PagedMultiCollectionsResponseModel": { + "totalCount": number; + "page": Array; }; "PagedPlayoutHistoryResponseModel": { "totalCount": number; @@ -891,6 +924,10 @@ export interface components { "PagedPlayoutsResponseModel": { "totalCount": number; "page": null | Array; + }; + "PagedRerunCollectionsResponseModel": { + "totalCount": number; + "page": Array; }; "PagedTraktListsResponseModel": { "totalCount": number; @@ -1139,6 +1176,15 @@ export interface components { "ReplaceTemplateRequest": { "name": null | string; "items": null | Array; + }; + "RerunCollectionResponseModel": { + "id": number; + "name": string; + "collectionType": components["schemas"]["CollectionType"]; + "selectedId": null | number; + "selectedName": null | string; + "firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"]; + "rerunPlaybackOrder": components["schemas"]["PlaybackOrder"]; }; "RerunCollectionViewModel": { "id": number; @@ -1468,6 +1514,10 @@ export interface components { "searchingMinimumLogLevel": components["schemas"]["LogEventLevel"]; "streamingMinimumLogLevel": components["schemas"]["LogEventLevel"]; "httpMinimumLogLevel": components["schemas"]["LogEventLevel"]; + }; + "UpdateMultiCollectionRequest": { + "name": null | string; + "items": null | Array; }; "UpdatePlayoutDetailsRequest": { "dailyRebuildTime": null | string; @@ -1477,6 +1527,13 @@ export interface components { "daysToBuild": number; "skipMissingItems": boolean; "scriptedScheduleTimeoutSeconds": number; + }; + "UpdateRerunCollectionRequest": { + "name": null | string; + "collectionType": components["schemas"]["CollectionType"]; + "selectedId": number; + "firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"]; + "rerunPlaybackOrder": components["schemas"]["PlaybackOrder"]; }; "UpdateScannerSettingsRequest": { "libraryRefreshInterval": number; diff --git a/web/src/api/multiCollections.ts b/web/src/api/multiCollections.ts new file mode 100644 index 000000000..308078175 --- /dev/null +++ b/web/src/api/multiCollections.ts @@ -0,0 +1,50 @@ +import { request } from './client'; +import type { components } from './generated/v1'; + +export type MultiCollection = components['schemas']['MultiCollectionResponseModel']; +export type MultiCollectionItem = components['schemas']['MultiCollectionItemResponseModel']; +export type PagedMultiCollections = components['schemas']['PagedMultiCollectionsResponseModel']; +export type CreateMultiCollectionRequest = components['schemas']['CreateMultiCollectionRequest']; +export type UpdateMultiCollectionRequest = components['schemas']['UpdateMultiCollectionRequest']; + +export interface GetMultiCollectionsParams { + query?: string; + pageNum?: number; + pageSize?: number; +} + +export function getMultiCollections(params: GetMultiCollectionsParams = {}): Promise { + const searchParams = new URLSearchParams(); + + if (params.query != null) { + searchParams.set('query', params.query); + } + + if (params.pageNum != null) { + searchParams.set('pageNum', String(params.pageNum)); + } + + if (params.pageSize != null) { + searchParams.set('pageSize', String(params.pageSize)); + } + + const queryString = searchParams.toString(); + + return request(`/api/multi-collections${queryString ? `?${queryString}` : ''}`); +} + +export function getMultiCollection(id: number): Promise { + return request(`/api/multi-collections/${id}`); +} + +export function createMultiCollection(body: CreateMultiCollectionRequest): Promise { + return request('/api/multi-collections', { body, method: 'POST' }); +} + +export function updateMultiCollection(id: number, body: UpdateMultiCollectionRequest): Promise { + return request(`/api/multi-collections/${id}`, { body, method: 'PUT' }); +} + +export function deleteMultiCollection(id: number): Promise { + return request(`/api/multi-collections/${id}`, { method: 'DELETE' }); +} diff --git a/web/src/api/rerunCollections.ts b/web/src/api/rerunCollections.ts new file mode 100644 index 000000000..e824a684b --- /dev/null +++ b/web/src/api/rerunCollections.ts @@ -0,0 +1,49 @@ +import { request } from './client'; +import type { components } from './generated/v1'; + +export type RerunCollection = components['schemas']['RerunCollectionResponseModel']; +export type PagedRerunCollections = components['schemas']['PagedRerunCollectionsResponseModel']; +export type CreateRerunCollectionRequest = components['schemas']['CreateRerunCollectionRequest']; +export type UpdateRerunCollectionRequest = components['schemas']['UpdateRerunCollectionRequest']; + +export interface GetRerunCollectionsParams { + query?: string; + pageNum?: number; + pageSize?: number; +} + +export function getRerunCollections(params: GetRerunCollectionsParams = {}): Promise { + const searchParams = new URLSearchParams(); + + if (params.query != null) { + searchParams.set('query', params.query); + } + + if (params.pageNum != null) { + searchParams.set('pageNum', String(params.pageNum)); + } + + if (params.pageSize != null) { + searchParams.set('pageSize', String(params.pageSize)); + } + + const queryString = searchParams.toString(); + + return request(`/api/rerun-collections${queryString ? `?${queryString}` : ''}`); +} + +export function getRerunCollection(id: number): Promise { + return request(`/api/rerun-collections/${id}`); +} + +export function createRerunCollection(body: CreateRerunCollectionRequest): Promise { + return request('/api/rerun-collections', { body, method: 'POST' }); +} + +export function updateRerunCollection(id: number, body: UpdateRerunCollectionRequest): Promise { + return request(`/api/rerun-collections/${id}`, { body, method: 'PUT' }); +} + +export function deleteRerunCollection(id: number): Promise { + return request(`/api/rerun-collections/${id}`, { method: 'DELETE' }); +}