Merge pull request 'feat(api): REST CRUD for multi-collections and rerun-collections (#151, #152)' (#188) from feat/multi-rerun-collections-api into main
This commit was merged in pull request #188.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaCollections;
|
||||
|
||||
public record MultiCollectionResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
List<MultiCollectionItemResponseModel> Items);
|
||||
|
||||
public record MultiCollectionItemResponseModel(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
string Name,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
|
||||
public record PagedMultiCollectionsResponseModel(
|
||||
int TotalCount,
|
||||
List<MultiCollectionResponseModel> Page);
|
||||
@@ -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<RerunCollectionResponseModel> Page);
|
||||
@@ -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<IMediator>();
|
||||
_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<GetPagedMultiCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedMultiCollectionsViewModel(1, new List<MultiCollectionViewModel> { 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<GetPagedMultiCollections>(q => q.Query == "k" && q.PageNum == 0 && q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MultiCollectionViewModel>.Some(MakeMulti(4, "Kids")));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MultiCollectionResponseModel>().Id.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MultiCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MultiCollectionViewModel>(MakeMulti(8, "Kids")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateMultiCollectionRequest(
|
||||
"Kids",
|
||||
new List<MultiCollectionItemRequest>
|
||||
{
|
||||
new(10, null, true, PlaybackOrder.Chronological),
|
||||
new(null, 20, false, PlaybackOrder.Shuffle)
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/multi-collections/8");
|
||||
created.Value.ShouldBeOfType<MultiCollectionResponseModel>().Name.ShouldBe("Kids");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateMultiCollection>(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<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MultiCollectionViewModel>(BaseError.New("dupe")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateMultiCollectionRequest("Kids", new List<MultiCollectionItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_With_Refreshed_Body_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MultiCollectionViewModel>.Some(MakeMulti(4, "Kids")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateMultiCollectionRequest(
|
||||
"Kids",
|
||||
new List<MultiCollectionItemRequest> { new(10, null, true, PlaybackOrder.Random) }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MultiCollectionResponseModel>().Id.ShouldBe(4);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateMultiCollection>(c =>
|
||||
c.MultiCollectionId == 4 &&
|
||||
c.Name == "Kids" &&
|
||||
c.Items.Count == 1 &&
|
||||
c.Items[0].CollectionId == 10 &&
|
||||
c.Items[0].PlaybackOrder == PlaybackOrder.Random),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateMultiCollectionRequest("Kids", new List<MultiCollectionItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeleteMultiCollection>(c => c.MultiCollectionId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_On_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new ErsatzTV.Core.Errors.NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
private static MultiCollectionViewModel MakeMulti(int id, string name) =>
|
||||
new(
|
||||
id,
|
||||
name,
|
||||
new List<MultiCollectionItemViewModel>
|
||||
{
|
||||
new(
|
||||
id,
|
||||
new MediaCollectionViewModel(CollectionType.Collection, 10, "Movies", false, MediaItemState.Normal),
|
||||
true,
|
||||
PlaybackOrder.Chronological)
|
||||
},
|
||||
new List<MultiCollectionSmartItemViewModel>
|
||||
{
|
||||
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<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<IMediator>();
|
||||
_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<GetPagedRerunCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedRerunCollectionsViewModel(
|
||||
1,
|
||||
new List<RerunCollectionViewModel> { 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<GetPagedRerunCollections>(q => q.Query == "n" && q.PageNum == 0 && q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<RerunCollectionViewModel>.Some(MakeRerunFromSmart(4, "Nightly")));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<RerunCollectionResponseModel>().Id.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<RerunCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_And_Resolve_Selected_Id_Into_Collection_Vm()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, RerunCollectionViewModel>(MakeRerunFromSmart(8, "Nightly")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.Collection,
|
||||
42,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Shuffle),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/rerun-collections/8");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateRerunCollection>(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<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Resolve_Media_Item_Selection_For_Show_Type()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, RerunCollectionViewModel>(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<CreateRerunCollection>(c =>
|
||||
c.CollectionType == CollectionType.TelevisionShow &&
|
||||
c.Collection == null &&
|
||||
c.MediaItem != null && c.MediaItem.MediaItemId == 77),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, RerunCollectionViewModel>(BaseError.New("dupe")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.SmartCollection,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[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<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_With_Refreshed_Body_And_Resolve_Selection()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<RerunCollectionViewModel>.Some(MakeRerunFromSmart(4, "Nightly")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.MultiCollection,
|
||||
55,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Shuffle),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<RerunCollectionResponseModel>().Id.ShouldBe(4);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateRerunCollection>(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<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.SmartCollection,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeleteRerunCollection>(c => c.RerunCollectionId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_On_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new ErsatzTV.Core.Errors.NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
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<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -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<PagedMultiCollectionsResponseModel> 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<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<MultiCollectionViewModel> 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<IActionResult> Create(
|
||||
[Required] [FromBody] CreateMultiCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, MultiCollectionViewModel> 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<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateMultiCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<MultiCollectionViewModel> 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<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteMultiCollection(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
private static MultiCollectionResponseModel ProjectToResponseModel(MultiCollectionViewModel vm)
|
||||
{
|
||||
List<MultiCollectionItemResponseModel> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<MultiCollectionItemRequest> Items)
|
||||
{
|
||||
public CreateMultiCollection ToCommand() =>
|
||||
new(
|
||||
Name,
|
||||
(Items ?? new List<MultiCollectionItemRequest>())
|
||||
.Select(i => new CreateMultiCollectionItem(
|
||||
i.CollectionId,
|
||||
i.SmartCollectionId,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
.ToList());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// The only <see cref="CollectionType" /> 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
|
||||
/// <see cref="ResolveSelection" />. Callers (the controller) must check this first.
|
||||
/// </summary>
|
||||
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))
|
||||
};
|
||||
}
|
||||
@@ -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<MultiCollectionItemRequest> Items)
|
||||
{
|
||||
public UpdateMultiCollection ToCommand(int id) =>
|
||||
new(
|
||||
id,
|
||||
Name,
|
||||
(Items ?? new List<MultiCollectionItemRequest>())
|
||||
.Select(i => new UpdateMultiCollectionItem(
|
||||
i.CollectionId,
|
||||
i.SmartCollectionId,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
.ToList());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<PagedRerunCollectionsResponseModel> 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<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<RerunCollectionViewModel> 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<IActionResult> 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<BaseError, RerunCollectionViewModel> 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<IActionResult> 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<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<RerunCollectionViewModel> 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<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> 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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+21
-1
@@ -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 |
|
||||
|
||||
Vendored
+57
@@ -432,12 +432,23 @@ export interface components {
|
||||
"playlistId": null | number;
|
||||
"expression": null | string;
|
||||
"useChaptersAsMediaItems": boolean;
|
||||
};
|
||||
"CreateMultiCollectionRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["MultiCollectionItemRequest"]>;
|
||||
};
|
||||
"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<components["schemas"]["MultiCollectionItemResponseModel"]>;
|
||||
};
|
||||
"MultiCollectionSmartItemViewModel": {
|
||||
"multiCollectionId": number;
|
||||
@@ -879,6 +908,10 @@ export interface components {
|
||||
"PagedLogEntriesResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["LogEntryResponseModel"]>;
|
||||
};
|
||||
"PagedMultiCollectionsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["MultiCollectionResponseModel"]>;
|
||||
};
|
||||
"PagedPlayoutHistoryResponseModel": {
|
||||
"totalCount": number;
|
||||
@@ -891,6 +924,10 @@ export interface components {
|
||||
"PagedPlayoutsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": null | Array<components["schemas"]["PlayoutListItemResponseModel"]>;
|
||||
};
|
||||
"PagedRerunCollectionsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["RerunCollectionResponseModel"]>;
|
||||
};
|
||||
"PagedTraktListsResponseModel": {
|
||||
"totalCount": number;
|
||||
@@ -1139,6 +1176,15 @@ export interface components {
|
||||
"ReplaceTemplateRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["TemplateItemRequest"]>;
|
||||
};
|
||||
"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<components["schemas"]["MultiCollectionItemRequest"]>;
|
||||
};
|
||||
"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;
|
||||
|
||||
@@ -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<PagedMultiCollections> {
|
||||
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<PagedMultiCollections>(`/api/multi-collections${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
export function getMultiCollection(id: number): Promise<MultiCollection> {
|
||||
return request<MultiCollection>(`/api/multi-collections/${id}`);
|
||||
}
|
||||
|
||||
export function createMultiCollection(body: CreateMultiCollectionRequest): Promise<MultiCollection> {
|
||||
return request<MultiCollection>('/api/multi-collections', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateMultiCollection(id: number, body: UpdateMultiCollectionRequest): Promise<MultiCollection> {
|
||||
return request<MultiCollection>(`/api/multi-collections/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteMultiCollection(id: number): Promise<void> {
|
||||
return request<void>(`/api/multi-collections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -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<PagedRerunCollections> {
|
||||
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<PagedRerunCollections>(`/api/rerun-collections${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
export function getRerunCollection(id: number): Promise<RerunCollection> {
|
||||
return request<RerunCollection>(`/api/rerun-collections/${id}`);
|
||||
}
|
||||
|
||||
export function createRerunCollection(body: CreateRerunCollectionRequest): Promise<RerunCollection> {
|
||||
return request<RerunCollection>('/api/rerun-collections', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateRerunCollection(id: number, body: UpdateRerunCollectionRequest): Promise<RerunCollection> {
|
||||
return request<RerunCollection>(`/api/rerun-collections/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteRerunCollection(id: number): Promise<void> {
|
||||
return request<void>(`/api/rerun-collections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
Reference in New Issue
Block a user