Merge pull request 'feat: playlist CRUD API + SPA editor (#153)' (#195) from feat/153-playlists into main
This commit was merged in pull request #195.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record RenamePlaylistGroup(int PlaylistGroupId, string Name)
|
||||
: IRequest<Either<BaseError, PlaylistGroupViewModel>>;
|
||||
@@ -0,0 +1,60 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<RenamePlaylistGroup, Either<BaseError, PlaylistGroupViewModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, PlaylistGroupViewModel>> Handle(
|
||||
RenamePlaylistGroup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, PlaylistGroup> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(playlistGroup => Persist(dbContext, request, playlistGroup));
|
||||
}
|
||||
|
||||
private static async Task<PlaylistGroupViewModel> Persist(
|
||||
TvContext dbContext,
|
||||
RenamePlaylistGroup request,
|
||||
PlaylistGroup playlistGroup)
|
||||
{
|
||||
playlistGroup.Name = request.Name;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Mapper.ProjectToViewModel(playlistGroup);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, PlaylistGroup>> Validate(
|
||||
TvContext dbContext,
|
||||
RenamePlaylistGroup request,
|
||||
CancellationToken cancellationToken) =>
|
||||
PlaylistGroupMustExist(dbContext, request, cancellationToken)
|
||||
.BindT(PlaylistGroupMustNotBeSystem)
|
||||
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup));
|
||||
|
||||
private static Task<Validation<BaseError, PlaylistGroup>> PlaylistGroupMustExist(
|
||||
TvContext dbContext,
|
||||
RenamePlaylistGroup request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.PlaylistGroups
|
||||
.Include(pg => pg.Playlists)
|
||||
.SelectOneAsync(pg => pg.Id, pg => pg.Id == request.PlaylistGroupId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>(
|
||||
new NotFoundError($"PlaylistGroup {request.PlaylistGroupId} does not exist.")));
|
||||
|
||||
// Plain BaseError (NOT NotFoundError) so it maps to 422, mirroring DeletePlaylistGroupHandler's
|
||||
// system-group guard. A missing group still surfaces as NotFoundError (404) from PlaylistGroupMustExist.
|
||||
private static Validation<BaseError, PlaylistGroup> PlaylistGroupMustNotBeSystem(PlaylistGroup playlistGroup) =>
|
||||
playlistGroup.IsSystem
|
||||
? BaseError.New("Cannot rename system playlist group")
|
||||
: playlistGroup;
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(RenamePlaylistGroup request) =>
|
||||
request.NotEmpty(x => x.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaCollections;
|
||||
|
||||
public record PlaylistItemResponseModel(
|
||||
int Id,
|
||||
int Index,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
string? CollectionName,
|
||||
int? MultiCollectionId,
|
||||
string? MultiCollectionName,
|
||||
int? SmartCollectionId,
|
||||
string? SmartCollectionName,
|
||||
int? MediaItemId,
|
||||
string? MediaItemName,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int? Count,
|
||||
bool PlayAll,
|
||||
bool IncludeInProgramGuide);
|
||||
@@ -0,0 +1,4 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.MediaCollections;
|
||||
|
||||
public record PlaylistPreviewItemResponseModel(string Title, string Start, string Finish, string Duration);
|
||||
@@ -0,0 +1,75 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
[TestFixture]
|
||||
public class PlaylistGroupHandlerTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task Rename_Should_Return_Error_When_Group_Missing()
|
||||
{
|
||||
var handler = new RenamePlaylistGroupHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await handler.Handle(new RenamePlaylistGroup(999, "Updated"), CancellationToken.None);
|
||||
|
||||
// The handler rejects a missing group; the API surfaces 404 for this case via the
|
||||
// controller's existence pre-check (LanguageExtensions.Apply collapses the handler's
|
||||
// NotFoundError to a plain BaseError, so the type is not asserted here).
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Rename_Should_Return_BaseError_When_Group_Is_System()
|
||||
{
|
||||
await SeedPlaylistGroup(5, "System", isSystem: true);
|
||||
|
||||
var handler = new RenamePlaylistGroupHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await handler.Handle(new RenamePlaylistGroup(5, "Renamed"), CancellationToken.None);
|
||||
|
||||
// Plain BaseError (not NotFoundError) so ApiResults maps it to 422, not 404.
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldBe("Cannot rename system playlist group");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Rename_Should_Succeed_For_Non_System_Group()
|
||||
{
|
||||
await SeedPlaylistGroup(6, "Kids", isSystem: false);
|
||||
|
||||
var handler = new RenamePlaylistGroupHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await handler.Handle(new RenamePlaylistGroup(6, "Family"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
result.Match(Left: _ => "", Right: vm => vm.Name).ShouldBe("Family");
|
||||
}
|
||||
|
||||
private async Task SeedPlaylistGroup(int id, string name, bool isSystem)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.PlaylistGroups.Add(new PlaylistGroup
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
IsSystem = isSystem,
|
||||
Playlists = []
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -151,6 +151,19 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/multi-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/multi-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/playlists/groups", "post", "422")]
|
||||
[TestCase("/api/playlists/groups/{id}", "put", "404")]
|
||||
[TestCase("/api/playlists/groups/{id}", "put", "422")]
|
||||
[TestCase("/api/playlists/groups/{id}", "delete", "404")]
|
||||
[TestCase("/api/playlists/groups/{id}", "delete", "422")]
|
||||
[TestCase("/api/playlists/{id}", "get", "404")]
|
||||
[TestCase("/api/playlists/{id}/items", "get", "404")]
|
||||
[TestCase("/api/playlists", "post", "422")]
|
||||
[TestCase("/api/playlists/{id}", "put", "404")]
|
||||
[TestCase("/api/playlists/{id}", "put", "422")]
|
||||
[TestCase("/api/playlists/{id}", "delete", "404")]
|
||||
[TestCase("/api/playlists/{id}", "delete", "422")]
|
||||
[TestCase("/api/playlists/preview", "post", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/rerun-collections", "post", "404")]
|
||||
[TestCase("/api/rerun-collections", "post", "422")]
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
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;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class PlaylistControllerTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new PlaylistController(_mediator);
|
||||
}
|
||||
|
||||
private PlaylistController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetGroups), "GET", "/api/playlists/groups");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.CreateGroup), "POST", "/api/playlists/groups");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.UpdateGroup), "PUT", "/api/playlists/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.DeleteGroup), "DELETE", "/api/playlists/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetAll), "GET", "/api/playlists");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetById), "GET", "/api/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetItems), "GET", "/api/playlists/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Create), "POST", "/api/playlists");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Update), "PUT", "/api/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Delete), "DELETE", "/api/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Preview), "POST", "/api/playlists/preview");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGroups_Should_Map_Response()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(3, "Kids", 2, false) });
|
||||
|
||||
List<PlaylistGroupResponseModel> result = await _controller.GetGroups(CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].Id.ShouldBe(3);
|
||||
result[0].PlaylistCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CreateGroup_Should_Return_201_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreatePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, PlaylistGroupViewModel>(new PlaylistGroupViewModel(8, "Kids", 0, false)));
|
||||
|
||||
IActionResult result = await _controller.CreateGroup(
|
||||
new CreatePlaylistGroupRequest("Kids"),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/playlists/groups/8");
|
||||
created.Value.ShouldBeOfType<PlaylistGroupResponseModel>().Name.ShouldBe("Kids");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreatePlaylistGroup>(c => c.Name == "Kids"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CreateGroup_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreatePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, PlaylistGroupViewModel>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.CreateGroup(
|
||||
new CreatePlaylistGroupRequest("Kids"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateGroup_Should_Return_200_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "Kids", 1, false) });
|
||||
_mediator.Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, PlaylistGroupViewModel>(new PlaylistGroupViewModel(4, "New Name", 1, false)));
|
||||
|
||||
IActionResult result = await _controller.UpdateGroup(
|
||||
4,
|
||||
new UpdatePlaylistGroupRequest("New Name"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlaylistGroupResponseModel>().Name
|
||||
.ShouldBe("New Name");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<RenamePlaylistGroup>(c => c.PlaylistGroupId == 4 && c.Name == "New Name"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateGroup_Should_Return_404_When_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel>());
|
||||
|
||||
IActionResult result = await _controller.UpdateGroup(
|
||||
4,
|
||||
new UpdatePlaylistGroupRequest("New Name"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateGroup_Should_Return_422_On_System_Group_And_Not_Rename()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "System", 0, true) });
|
||||
|
||||
IActionResult result = await _controller.UpdateGroup(
|
||||
4,
|
||||
new UpdatePlaylistGroupRequest("New Name"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateGroup_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "Kids", 1, false) });
|
||||
_mediator.Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, PlaylistGroupViewModel>(BaseError.New("too long")));
|
||||
|
||||
IActionResult result = await _controller.UpdateGroup(
|
||||
4,
|
||||
new UpdatePlaylistGroupRequest("New Name"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteGroup_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "Kids", 0, false) });
|
||||
_mediator.Send(Arg.Any<DeletePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
|
||||
IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeletePlaylistGroup>(c => c.PlaylistGroupId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteGroup_Should_Return_404_When_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel>());
|
||||
|
||||
IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlaylistGroup>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteGroup_Should_Return_422_On_System_Group()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "System", 0, true) });
|
||||
_mediator.Send(Arg.Any<DeletePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.Some(BaseError.New("Cannot delete system playlist group")));
|
||||
|
||||
IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlaylistResponseModel>().Id.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_200_And_Flatten_Names()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>
|
||||
{
|
||||
new(
|
||||
100,
|
||||
0,
|
||||
CollectionType.Collection,
|
||||
new MediaCollectionViewModel(CollectionType.Collection, 10, "Movies", false, MediaItemState.Normal),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PlaybackOrder.Chronological,
|
||||
null,
|
||||
true,
|
||||
false),
|
||||
new(
|
||||
101,
|
||||
1,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new NamedMediaItemViewModel(55, "The Movie"),
|
||||
PlaybackOrder.Shuffle,
|
||||
3,
|
||||
false,
|
||||
true)
|
||||
});
|
||||
|
||||
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
var items = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<PlaylistItemResponseModel>>();
|
||||
items.Count.ShouldBe(2);
|
||||
items[0].CollectionId.ShouldBe(10);
|
||||
items[0].CollectionName.ShouldBe("Movies");
|
||||
items[0].MediaItemId.ShouldBeNull();
|
||||
items[1].MediaItemId.ShouldBe(55);
|
||||
items[1].MediaItemName.ShouldBe("The Movie");
|
||||
items[1].CollectionName.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_404_When_Playlist_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreatePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, PlaylistViewModel>(new PlaylistViewModel(9, 1, "Kids", false)));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreatePlaylistRequest(1, "Kids"),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/playlists/9");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreatePlaylist>(c => c.PlaylistGroupId == 1 && c.Name == "Kids"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreatePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, PlaylistViewModel>(BaseError.New("dupe")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreatePlaylistRequest(1, "Kids"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_With_Items_And_Map_Request_By_Array_Order()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>
|
||||
{
|
||||
new(
|
||||
100,
|
||||
0,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new NamedMediaItemViewModel(55, "The Movie"),
|
||||
PlaybackOrder.Shuffle,
|
||||
null,
|
||||
false,
|
||||
true)
|
||||
}));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest(
|
||||
"Kids",
|
||||
new List<PlaylistItemRequest>
|
||||
{
|
||||
new(99, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true),
|
||||
new(99, CollectionType.Collection, 10, null, null, null, PlaybackOrder.Chronological, null, true,
|
||||
false)
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<PlaylistItemResponseModel>>().Count
|
||||
.ShouldBe(1);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlaylistItems>(c =>
|
||||
c.PlaylistId == 4 &&
|
||||
c.Name == "Kids" &&
|
||||
c.Items.Count == 2 &&
|
||||
c.Items[0].Index == 0 &&
|
||||
c.Items[0].MediaItemId == 55 &&
|
||||
c.Items[1].Index == 1 &&
|
||||
c.Items[1].CollectionId == 10),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_404_When_Playlist_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, List<PlaylistItemViewModel>>(BaseError.New("bad item")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_System_Playlist_And_Not_Replace_Items()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest(
|
||||
"Renamed",
|
||||
new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeletePlaylist>(c => c.PlaylistId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_When_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_422_On_System_Playlist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
|
||||
_mediator.Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.Some(BaseError.New("Cannot delete system (generated) playlist")));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Preview_Should_Return_200_And_Format_Times()
|
||||
{
|
||||
_mediator.Send(Arg.Any<PreviewPlaylistPlayout>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<PlayoutItemPreviewViewModel>>(new List<PlayoutItemPreviewViewModel>
|
||||
{
|
||||
new("The Movie", new TimeSpan(1, 2, 3), new TimeSpan(2, 4, 6), "1:02:03")
|
||||
}));
|
||||
|
||||
IActionResult result = await _controller.Preview(
|
||||
new ReplacePlaylistRequest(
|
||||
"Draft",
|
||||
new List<PlaylistItemRequest>
|
||||
{
|
||||
new(0, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true)
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
var items = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PlaylistPreviewItemResponseModel>>();
|
||||
items.Count.ShouldBe(1);
|
||||
items[0].Title.ShouldBe("The Movie");
|
||||
items[0].Start.ShouldBe("01:02:03");
|
||||
items[0].Finish.ShouldBe("02:04:06");
|
||||
items[0].Duration.ShouldBe("1:02:03");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<PreviewPlaylistPlayout>(c => c.Data.PlaylistId == 0 && c.Data.Items.Count == 1),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Preview_Should_Return_422_On_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<PreviewPlaylistPlayout>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, List<PlayoutItemPreviewViewModel>>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Preview(
|
||||
new ReplacePlaylistRequest("Draft", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Preview_Should_Return_422_When_Item_Missing_Required_Id()
|
||||
{
|
||||
IActionResult result = await _controller.Preview(
|
||||
new ReplacePlaylistRequest(
|
||||
"Draft",
|
||||
new List<PlaylistItemRequest>
|
||||
{
|
||||
// Collection-type item with a null collectionId is invalid.
|
||||
new(0, CollectionType.Collection, null, null, null, null, PlaybackOrder.Chronological, null, true,
|
||||
false)
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<PreviewPlaylistPlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(PlaylistController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
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;
|
||||
@@ -17,7 +23,85 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<PlaylistGroupResponseModel>> GetGroups(CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlaylistGroupViewModel> groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
|
||||
return groups.Map(g => new PlaylistGroupResponseModel(g.Id, g.Name, g.PlaylistCount, g.IsSystem)).ToList();
|
||||
return groups.Map(ProjectToGroupResponse).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists/groups", Name = "CreatePlaylistGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Create a playlist group")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlaylistGroupResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> CreateGroup(
|
||||
[Required] [FromBody] CreatePlaylistGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
g => $"/api/playlists/groups/{g.Id}",
|
||||
ProjectToGroupResponse);
|
||||
}
|
||||
|
||||
[HttpPut("/api/playlists/groups/{id:int}", Name = "UpdatePlaylistGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Rename a playlist group")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlaylistGroupResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateGroup(
|
||||
int id,
|
||||
[Required] [FromBody] UpdatePlaylistGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Existence pre-check (404) mirrors DeleteGroup. Required controller-side because the
|
||||
// handler's NotFoundError is collapsed to a plain BaseError by LanguageExtensions.Apply
|
||||
// (error.Join()), so a missing group would otherwise map to 422 instead of 404.
|
||||
List<PlaylistGroupViewModel> groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
|
||||
Option<PlaylistGroupViewModel> maybeGroup = groups.Find(g => g.Id == id);
|
||||
if (maybeGroup.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// System (generated) groups must not be renamed. The RenamePlaylistGroupHandler also
|
||||
// enforces this (defense-in-depth for the Blazor path); catching it here keeps the API's
|
||||
// 422 independent of the handler's error-collapsing.
|
||||
foreach (PlaylistGroupViewModel group in maybeGroup)
|
||||
{
|
||||
if (group.IsSystem)
|
||||
{
|
||||
return BaseError.New("Cannot rename system playlist group").ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: vm => (IActionResult)new OkObjectResult(ProjectToGroupResponse(vm)));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playlists/groups/{id:int}", Name = "DeletePlaylistGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Delete a playlist group")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> DeleteGroup(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlaylistGroupViewModel> groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
|
||||
if (groups.All(g => g.Id != id))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Option<BaseError> result = await mediator.Send(new DeletePlaylistGroup(id), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Some: error => error.ToErrorResult(),
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playlists", Name = "GetPlaylists")]
|
||||
@@ -32,6 +116,170 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
List<PlaylistViewModel> playlists =
|
||||
await mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroupId), cancellationToken);
|
||||
return playlists.Map(p => new PlaylistResponseModel(p.Id, p.PlaylistGroupId, p.Name, p.IsSystem)).ToList();
|
||||
return playlists.Map(ProjectToPlaylistResponse).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playlists/{id:int}", Name = "GetPlaylistById")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Get a playlist by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlaylistResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlaylistViewModel> result = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
return result.Map(ProjectToPlaylistResponse).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playlists/{id:int}/items", Name = "GetPlaylistItems")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Get the items in a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
if (maybePlaylist.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<PlaylistItemViewModel> items = await mediator.Send(new GetPlaylistItems(id), cancellationToken);
|
||||
return new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists", Name = "CreatePlaylistInGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Create a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlaylistResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreatePlaylistRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, PlaylistViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
p => $"/api/playlists/{p.Id}",
|
||||
ProjectToPlaylistResponse);
|
||||
}
|
||||
|
||||
[HttpPut("/api/playlists/{id:int}", Name = "UpdatePlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Update a playlist (rename and replace its items)")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlaylistRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
if (maybePlaylist.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// System (generated) playlists must not be renamed or have their items replaced.
|
||||
// Mirrors the DeletePlaylist system-guard (422); done controller-side so item
|
||||
// replacement never reaches ReplacePlaylistItemsHandler for a generated playlist.
|
||||
foreach (PlaylistViewModel playlist in maybePlaylist)
|
||||
{
|
||||
if (playlist.IsSystem)
|
||||
{
|
||||
return BaseError.New("Cannot modify system (generated) playlist").ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: items => (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList()));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playlists/{id:int}", Name = "DeletePlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Delete a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
if (maybePlaylist.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Option<BaseError> result = await mediator.Send(new DeletePlaylist(id), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Some: error => error.ToErrorResult(),
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists/preview", Name = "PreviewPlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Preview the playout of a draft playlist")]
|
||||
[EndpointDescription("Builds a preview playout from the posted draft playlist items (no persistence).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlaylistPreviewItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Preview(
|
||||
[Required] [FromBody] ReplacePlaylistRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The preview path does not run the handler's CollectionTypesMustBeValid check
|
||||
// (PreviewPlaylistPlayoutHandler is shared with Blazor and left unchanged), so
|
||||
// validate the draft at the controller boundary to avoid a 500 in the playout
|
||||
// builder on an item missing the id required for its collection type.
|
||||
List<int> invalidItems = request.ItemsMissingRequiredId();
|
||||
if (invalidItems.Count > 0)
|
||||
{
|
||||
return BaseError
|
||||
.New($"Invalid playlist item(s) at index(es): {string.Join(", ", invalidItems)}")
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
Either<BaseError, List<PlayoutItemPreviewViewModel>> result =
|
||||
await mediator.Send(new PreviewPlaylistPlayout(request.ToReplaceCommand()), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: items => (IActionResult)new OkObjectResult(items.Map(ProjectToPreviewResponse).ToList()));
|
||||
}
|
||||
|
||||
private static PlaylistGroupResponseModel ProjectToGroupResponse(PlaylistGroupViewModel vm) =>
|
||||
new(vm.Id, vm.Name, vm.PlaylistCount, vm.IsSystem);
|
||||
|
||||
private static PlaylistResponseModel ProjectToPlaylistResponse(PlaylistViewModel vm) =>
|
||||
new(vm.Id, vm.PlaylistGroupId, vm.Name, vm.IsSystem);
|
||||
|
||||
private static PlaylistItemResponseModel ProjectToItemResponse(PlaylistItemViewModel vm) =>
|
||||
new(
|
||||
vm.Id,
|
||||
vm.Index,
|
||||
vm.CollectionType,
|
||||
vm.Collection?.Id,
|
||||
vm.Collection?.Name,
|
||||
vm.MultiCollection?.Id,
|
||||
vm.MultiCollection?.Name,
|
||||
vm.SmartCollection?.Id,
|
||||
vm.SmartCollection?.Name,
|
||||
vm.MediaItem?.MediaItemId,
|
||||
vm.MediaItem?.Name,
|
||||
vm.PlaybackOrder,
|
||||
vm.Count,
|
||||
vm.PlayAll,
|
||||
vm.IncludeInProgramGuide);
|
||||
|
||||
private static PlaylistPreviewItemResponseModel ProjectToPreviewResponse(PlayoutItemPreviewViewModel vm) =>
|
||||
new(
|
||||
vm.Title,
|
||||
vm.Start.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
|
||||
vm.Finish.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
|
||||
vm.Duration);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreatePlaylistGroupRequest(string? Name)
|
||||
{
|
||||
public CreatePlaylistGroup ToCommand() => new(Name ?? string.Empty);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreatePlaylistRequest(int PlaylistGroupId, string? Name)
|
||||
{
|
||||
public CreatePlaylist ToCommand() => new(PlaylistGroupId, Name ?? string.Empty);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record PlaylistItemRequest(
|
||||
int Index,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? MediaItemId,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int? Count,
|
||||
bool PlayAll,
|
||||
bool IncludeInProgramGuide)
|
||||
{
|
||||
public ReplacePlaylistItem ToReplaceCommand(int index) =>
|
||||
new(
|
||||
index,
|
||||
CollectionType,
|
||||
CollectionId,
|
||||
MultiCollectionId,
|
||||
SmartCollectionId,
|
||||
MediaItemId,
|
||||
PlaybackOrder,
|
||||
Count,
|
||||
PlayAll,
|
||||
IncludeInProgramGuide);
|
||||
|
||||
// Mirrors ReplacePlaylistItemsHandler.CollectionTypeMustBeValid: the id required for the
|
||||
// item's collection type must be present. Kept in sync with that handler so the PUT and
|
||||
// preview endpoints enforce the same rule (the handler operates on the command type in the
|
||||
// Application layer and cannot reference this request DTO, hence the parallel logic).
|
||||
public bool HasRequiredId() =>
|
||||
CollectionType switch
|
||||
{
|
||||
CollectionType.Collection => CollectionId is not null,
|
||||
CollectionType.MultiCollection => MultiCollectionId is not null,
|
||||
CollectionType.SmartCollection => SmartCollectionId is not null,
|
||||
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 => MediaItemId is not null,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public record ReplacePlaylistRequest(string? Name, List<PlaylistItemRequest>? Items)
|
||||
{
|
||||
public ReplacePlaylistItems ToCommand(int id) =>
|
||||
new(id, Name ?? string.Empty, BuildItems());
|
||||
|
||||
// Preview operates on the posted draft, so there is no persisted playlist id (0).
|
||||
public ReplacePlaylistItems ToReplaceCommand() =>
|
||||
new(0, Name ?? string.Empty, BuildItems());
|
||||
|
||||
// Array indexes of draft items missing the id required for their collection type.
|
||||
public List<int> ItemsMissingRequiredId()
|
||||
{
|
||||
List<PlaylistItemRequest> items = Items ?? new List<PlaylistItemRequest>();
|
||||
var invalid = new List<int>();
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
if (!items[i].HasRequiredId())
|
||||
{
|
||||
invalid.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
return invalid;
|
||||
}
|
||||
|
||||
private List<ReplacePlaylistItem> BuildItems() =>
|
||||
(Items ?? new List<PlaylistItemRequest>())
|
||||
.Select((item, index) => item.ToReplaceCommand(index))
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record UpdatePlaylistGroupRequest(string? Name)
|
||||
{
|
||||
public RenamePlaylistGroup ToCommand(int id) => new(id, Name ?? string.Empty);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ been added to the redirect map yet.
|
||||
| `/media/filler/presets`(`/add`, `/{Id}/edit`) | `FillerPresets.razor`, `FillerPresetEditor.razor` | `/app/filler-presets` | allowSubPaths |
|
||||
| `/media/collections`(`/add`, `/{Id}/edit`, `/{Id}`) | `ManualCollections.razor`, `CollectionEditor.razor`, `CollectionItems.razor` + `SmartCollections.razor`/`SmartCollectionEditor.razor` | `/app/collections` | allowSubPaths |
|
||||
| `/media/trash` | `Trash.razor` | `/app/trash` | |
|
||||
| `/media/playlists`(`/{Id}`) | `Playlists.razor`, `PlaylistEditor.razor` | `/app/collections` | merged into collections screen |
|
||||
| `/media/playlists`(`/{Id}`) | `Playlists.razor`, `PlaylistEditor.razor` | `/app/playlists` | SPA DONE (#153): group tree + playlist item editor + playout preview (`PlaylistsScreen`) |
|
||||
| `/media/trakt/lists`(`/{Id}`) | `TraktLists.razor`, `TraktListEditor.razor` | `/app/trakt-lists`(`/{id}`) | allowSubPaths |
|
||||
| `/ffmpeg`(`/add`, `/{Id}`) | `FFmpeg.razor`, `FFmpegEditor.razor` | `/app/ffmpeg-profiles` | allowSubPaths |
|
||||
| `/watermarks`(`/add`, `/{Id}`) | `Watermarks.razor`, `WatermarkEditor.razor` | `/app/watermarks` | allowSubPaths |
|
||||
@@ -102,17 +102,22 @@ been added to the redirect map yet.
|
||||
|
||||
## Section 3 — BLAZOR-ONLY (blocking issues)
|
||||
|
||||
### Playlist variant management — API gap #153
|
||||
### Playlist variant management — API gap #153 RESOLVED
|
||||
|
||||
**#151 (multi-collections) and #152 (rerun collections) are DONE** — both now have SPA editor screens
|
||||
(`/app/multi-collections`, `/app/rerun-collections`; see Section 2). The rerun editor offers the
|
||||
REST-supported selection types only (Collection, MultiCollection, SmartCollection, and the media-item
|
||||
types TelevisionShow/TelevisionSeason/Artist/Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream);
|
||||
**#151 (multi-collections), #152 (rerun collections), and #153 (playlists) are DONE** — all three now
|
||||
have SPA editor screens (`/app/multi-collections`, `/app/rerun-collections`, `/app/playlists`; see
|
||||
Section 2). The rerun editor offers the REST-supported selection types only (Collection,
|
||||
MultiCollection, SmartCollection, and the media-item types
|
||||
TelevisionShow/TelevisionSeason/Artist/Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream);
|
||||
Playlist is intentionally excluded, matching `RerunCollectionRequestMapping.IsSupportedSelectionType`.
|
||||
|
||||
| Blazor route | File | Blocking issue |
|
||||
|---|---|---|
|
||||
| `/media/playlists`(`/{Id}`) editing depth beyond what `/app/collections` covers | `Playlists.razor`, `PlaylistEditor.razor` | #153 (playlist variant management API) |
|
||||
**#153**: the playlist CRUD REST API (`/api/playlists/*` — groups, playlists, item-list replace, and
|
||||
draft playout preview) plus the `/app/playlists` SPA screen (`PlaylistsScreen`) now mirror
|
||||
`Playlists.razor` (group tree, add/rename/delete groups, add/delete playlists) and
|
||||
`PlaylistEditor.razor` (per-item Collection Type over the 12 playlist item types — Collection,
|
||||
TelevisionShow, TelevisionSeason, Artist, MultiCollection, SmartCollection, Movie, Episode, MusicVideo,
|
||||
OtherVideo, Song, Image; type-conditional playback order; count; Play All; Show In EPG; reorder/copy/
|
||||
remove; playout preview). `IsSystem` groups and playlists are read-only in the SPA, matching Blazor.
|
||||
|
||||
**#155 RESOLVED** (collection-items enumeration): `GET /api/collections/{id}/items` (paged) now returns a
|
||||
manual collection's full contents across all media kinds (reusing `LibraryBrowseItemResponseModel`), so the
|
||||
|
||||
@@ -61,7 +61,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
|
||||
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). | `MultiCollection` | `/app/multi-collections` (#151) |
|
||||
| **RerunCollection** | One source (collection/media item/multi/smart) with separate `FirstRunPlaybackOrder` vs `RerunPlaybackOrder`. | `RerunCollection` | `/app/rerun-collections` (#152) |
|
||||
| **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/collections` |
|
||||
| **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/playlists` |
|
||||
| **Media kinds** | `CollectionType` enum distinguishes container kinds (Collection/TelevisionShow/TelevisionSeason/Artist/MultiCollection/SmartCollection/Playlist/RerunFirstRun/RerunRerun/SearchQuery) from leaf media kinds (Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream) plus synthetic `FakeCollection`/`FakePlaylistItem`. Concrete media entities: `Movie`, `Show`/`Season`/`Episode`, `Artist`/`MusicVideo`/`Song`, `OtherVideo`, `Image`, `RemoteStream` (`ErsatzTV.Core/Domain/MediaItem/`). | `MediaItem` subclasses | `/app/media?kind=...` |
|
||||
| **Library / LibraryPath / LibraryFolder** | `Library` (abstract; Local/Plex/Jellyfin/Emby subclasses) owns one or more `LibraryPath`s (scan roots); each path has a `LibraryFolder` tree used for browsing and image-folder duration metadata. | `Library`, `LibraryPath`, `LibraryFolder` | `/app/libraries` |
|
||||
| **Media source kind** | `MediaSourceKind`: Local/Plex/Jellyfin/Emby — the origin server type for a `Library`. | `MediaSourceKind` | `/app/libraries` |
|
||||
@@ -94,10 +94,9 @@ validator; playback troubleshooting still gated on API #145), `/app/ffmpeg-profi
|
||||
Per-item media detail pages and the image-folder browser (`MediaDetailScreen`'s
|
||||
`MovieDetailScreen`/`ShowDetailScreen`/`SeasonDetailScreen`/`ArtistDetailScreen`,
|
||||
`ImageBrowserScreen`) landed via #141 (PR #183) at `/app/media/{movies|shows|seasons|artists}/{id}`
|
||||
and `/app/media/images/browser`. Multi-collection (#151) and rerun-collection (#152) editors now
|
||||
live in the SPA at `/app/multi-collections` and `/app/rerun-collections`. Not yet in the SPA:
|
||||
playlist-variant management depth (API gap #153). See `docs/blazor-route-parity.md` for the full
|
||||
route-by-route tracker.
|
||||
and `/app/media/images/browser`. Multi-collection (#151), rerun-collection (#152), and playlist
|
||||
(#153) editors now live in the SPA at `/app/multi-collections`, `/app/rerun-collections`, and
|
||||
`/app/playlists`. See `docs/blazor-route-parity.md` for the full route-by-route tracker.
|
||||
|
||||
## Key handler / file locations
|
||||
|
||||
|
||||
+10
-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`.*
|
||||
|
||||
118 endpoints, 186 operations.
|
||||
122 endpoints, 195 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
@@ -203,7 +203,16 @@
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/playlists` | GetPlaylists | Get playlists in a playlist group |
|
||||
| POST | `/api/playlists` | CreatePlaylistInGroup | Create a playlist |
|
||||
| GET | `/api/playlists/groups` | GetPlaylistGroups | Get all playlist groups |
|
||||
| POST | `/api/playlists/groups` | CreatePlaylistGroup | Create a playlist group |
|
||||
| DELETE | `/api/playlists/groups/{id}` | DeletePlaylistGroup | Delete a playlist group |
|
||||
| PUT | `/api/playlists/groups/{id}` | UpdatePlaylistGroup | Rename a playlist group |
|
||||
| POST | `/api/playlists/preview` | PreviewPlaylist | Preview the playout of a draft playlist |
|
||||
| DELETE | `/api/playlists/{id}` | DeletePlaylist | Delete a playlist |
|
||||
| GET | `/api/playlists/{id}` | GetPlaylistById | Get a playlist by id |
|
||||
| PUT | `/api/playlists/{id}` | UpdatePlaylist | Update a playlist (rename and replace its items) |
|
||||
| GET | `/api/playlists/{id}/items` | GetPlaylistItems | Get the items in a playlist |
|
||||
|
||||
## Playouts
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Library,
|
||||
Link2,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
ListVideo,
|
||||
MonitorPlay,
|
||||
Music,
|
||||
@@ -72,6 +73,7 @@ import { FillerPresetsScreen } from './screens/FillerPresetsScreen';
|
||||
import { LogsScreen } from './screens/LogsScreen';
|
||||
import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
|
||||
import { MultiCollectionsScreen } from './screens/MultiCollectionsScreen';
|
||||
import { PlaylistsScreen } from './screens/PlaylistsScreen';
|
||||
import { RerunCollectionsScreen } from './screens/RerunCollectionsScreen';
|
||||
import {
|
||||
ArtistDetailScreen,
|
||||
@@ -189,6 +191,7 @@ type ScreenId =
|
||||
| 'collections'
|
||||
| 'multiCollections'
|
||||
| 'rerunCollections'
|
||||
| 'playlists'
|
||||
| 'fillerPresets'
|
||||
| 'libraries'
|
||||
| 'traktLists'
|
||||
@@ -422,6 +425,17 @@ const routes: ScreenRoute[] = [
|
||||
primaryAction: 'Add Rerun Collection',
|
||||
placeholder: 'Rerun collections workspace'
|
||||
},
|
||||
{
|
||||
id: 'playlists',
|
||||
path: '/app/playlists',
|
||||
label: 'Playlists',
|
||||
title: 'Playlists',
|
||||
kicker: 'Media',
|
||||
description: 'Group, order, and preview playlist items scheduled into channels.',
|
||||
icon: <ListMusic aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Playlist',
|
||||
placeholder: 'Playlists workspace'
|
||||
},
|
||||
{
|
||||
id: 'fillerPresets',
|
||||
path: '/app/filler-presets',
|
||||
@@ -562,6 +576,7 @@ const mediaNavIds: ScreenId[] = [
|
||||
'collections',
|
||||
'multiCollections',
|
||||
'rerunCollections',
|
||||
'playlists',
|
||||
'fillerPresets',
|
||||
'libraries',
|
||||
'traktLists'
|
||||
@@ -3729,6 +3744,10 @@ function ScreenContent({
|
||||
return <RerunCollectionsScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'playlists') {
|
||||
return <PlaylistsScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'traktLists') {
|
||||
return <TraktListsScreen key={window.location.pathname} />;
|
||||
}
|
||||
|
||||
Vendored
+49
@@ -436,6 +436,13 @@ export interface components {
|
||||
"CreateMultiCollectionRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["MultiCollectionItemRequest"]>;
|
||||
};
|
||||
"CreatePlaylistGroupRequest": {
|
||||
"name": null | string;
|
||||
};
|
||||
"CreatePlaylistRequest": {
|
||||
"playlistGroupId": number;
|
||||
"name": null | string;
|
||||
};
|
||||
"CreatePlayoutRequest": {
|
||||
"channelId": number;
|
||||
@@ -939,6 +946,41 @@ export interface components {
|
||||
"name": string;
|
||||
"playlistCount": number;
|
||||
"isSystem": boolean;
|
||||
};
|
||||
"PlaylistItemRequest": {
|
||||
"index": number;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"collectionId": null | number;
|
||||
"multiCollectionId": null | number;
|
||||
"smartCollectionId": null | number;
|
||||
"mediaItemId": null | number;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"count": null | number;
|
||||
"playAll": boolean;
|
||||
"includeInProgramGuide": boolean;
|
||||
};
|
||||
"PlaylistItemResponseModel": {
|
||||
"id": number;
|
||||
"index": number;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"collectionId": null | number;
|
||||
"collectionName": null | string;
|
||||
"multiCollectionId": null | number;
|
||||
"multiCollectionName": null | string;
|
||||
"smartCollectionId": null | number;
|
||||
"smartCollectionName": null | string;
|
||||
"mediaItemId": null | number;
|
||||
"mediaItemName": null | string;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"count": null | number;
|
||||
"playAll": boolean;
|
||||
"includeInProgramGuide": boolean;
|
||||
};
|
||||
"PlaylistPreviewItemResponseModel": {
|
||||
"title": string;
|
||||
"start": string;
|
||||
"finish": string;
|
||||
"duration": string;
|
||||
};
|
||||
"PlaylistResponseModel": {
|
||||
"id": number;
|
||||
@@ -1163,6 +1205,10 @@ export interface components {
|
||||
"ReplaceDecoTemplateRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["DecoTemplateItemRequest"]>;
|
||||
};
|
||||
"ReplacePlaylistRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["PlaylistItemRequest"]>;
|
||||
};
|
||||
"ReplacePlayoutAlternateSchedulesRequest": {
|
||||
"items": null | Array<components["schemas"]["PlayoutAlternateScheduleItemRequest"]>;
|
||||
@@ -1518,6 +1564,9 @@ export interface components {
|
||||
"UpdateMultiCollectionRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["MultiCollectionItemRequest"]>;
|
||||
};
|
||||
"UpdatePlaylistGroupRequest": {
|
||||
"name": null | string;
|
||||
};
|
||||
"UpdatePlayoutDetailsRequest": {
|
||||
"dailyRebuildTime": null | string;
|
||||
|
||||
@@ -1,10 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getPlaylistGroups, getPlaylists } from './playlists';
|
||||
import {
|
||||
createPlaylist,
|
||||
createPlaylistGroup,
|
||||
deletePlaylist,
|
||||
deletePlaylistGroup,
|
||||
getPlaylistById,
|
||||
getPlaylistGroups,
|
||||
getPlaylistItems,
|
||||
getPlaylists,
|
||||
messageFromPlaylistError,
|
||||
previewPlaylist,
|
||||
updatePlaylist,
|
||||
updatePlaylistGroup,
|
||||
type PlaylistItemRequest
|
||||
} from './playlists';
|
||||
import { ApiError } from './client';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function lastCall(fetchMock: ReturnType<typeof vi.spyOn>) {
|
||||
const call = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
|
||||
return { init: call[1] as RequestInit | undefined, url: String(call[0]) };
|
||||
}
|
||||
|
||||
const sampleItem: PlaylistItemRequest = {
|
||||
collectionId: 7,
|
||||
collectionType: 'Collection',
|
||||
count: null,
|
||||
includeInProgramGuide: true,
|
||||
index: 0,
|
||||
mediaItemId: null,
|
||||
multiCollectionId: null,
|
||||
playAll: true,
|
||||
playbackOrder: 'Chronological',
|
||||
smartCollectionId: null
|
||||
};
|
||||
|
||||
describe('playlists api client', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
@@ -14,14 +51,88 @@ describe('playlists api client', () => {
|
||||
it('getPlaylistGroups fetches all playlist groups', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse([{ id: 1, name: 'Idents', playlistCount: 2, isSystem: false }]));
|
||||
.mockResolvedValue(jsonResponse([{ id: 1, isSystem: false, name: 'Idents', playlistCount: 2 }]));
|
||||
await expect(getPlaylistGroups()).resolves.toHaveLength(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/groups', expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('createPlaylistGroup POSTs the name', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 5, isSystem: false, name: 'New', playlistCount: 0 }, 201));
|
||||
await createPlaylistGroup({ name: 'New' });
|
||||
const { init, url } = lastCall(fetchMock);
|
||||
expect(url).toBe('/api/playlists/groups');
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ name: 'New' });
|
||||
});
|
||||
|
||||
it('updatePlaylistGroup PUTs the renamed group', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 5, isSystem: false, name: 'Renamed', playlistCount: 0 }));
|
||||
await updatePlaylistGroup(5, { name: 'Renamed' });
|
||||
const { init, url } = lastCall(fetchMock);
|
||||
expect(url).toBe('/api/playlists/groups/5');
|
||||
expect(init?.method).toBe('PUT');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ name: 'Renamed' });
|
||||
});
|
||||
|
||||
it('deletePlaylistGroup DELETEs the group', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
await deletePlaylistGroup(9);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/groups/9', expect.objectContaining({ method: 'DELETE' }));
|
||||
});
|
||||
|
||||
it('getPlaylists filters by playlist group id', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
await getPlaylists(3);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/playlists?playlistGroupId=3', expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('getPlaylistById fetches a single playlist', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, isSystem: false, name: 'Bumps', playlistGroupId: 3 }));
|
||||
await expect(getPlaylistById(4)).resolves.toMatchObject({ id: 4 });
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/4', expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('getPlaylistItems fetches the items of a playlist', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
await getPlaylistItems(4);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/4/items', expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('createPlaylist POSTs the group id and name', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 8, isSystem: false, name: 'Bumps', playlistGroupId: 3 }, 201));
|
||||
await createPlaylist({ name: 'Bumps', playlistGroupId: 3 });
|
||||
const { init, url } = lastCall(fetchMock);
|
||||
expect(url).toBe('/api/playlists');
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ name: 'Bumps', playlistGroupId: 3 });
|
||||
});
|
||||
|
||||
it('updatePlaylist PUTs the name and item list', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
await updatePlaylist(4, { items: [sampleItem], name: 'Bumps' });
|
||||
const { init, url } = lastCall(fetchMock);
|
||||
expect(url).toBe('/api/playlists/4');
|
||||
expect(init?.method).toBe('PUT');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ items: [sampleItem], name: 'Bumps' });
|
||||
});
|
||||
|
||||
it('deletePlaylist DELETEs the playlist', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
await deletePlaylist(4);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/4', expect.objectContaining({ method: 'DELETE' }));
|
||||
});
|
||||
|
||||
it('previewPlaylist POSTs the draft to the preview endpoint', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
await previewPlaylist({ items: [sampleItem], name: 'Draft' });
|
||||
const { init, url } = lastCall(fetchMock);
|
||||
expect(url).toBe('/api/playlists/preview');
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ items: [sampleItem], name: 'Draft' });
|
||||
});
|
||||
|
||||
it('messageFromPlaylistError prefers ApiError detail', () => {
|
||||
expect(messageFromPlaylistError(new ApiError(422, { detail: 'Name is required' }))).toBe('Name is required');
|
||||
expect(messageFromPlaylistError('nope', 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,15 +3,59 @@ import type { components } from './generated/v1';
|
||||
|
||||
export type PlaylistGroup = components['schemas']['PlaylistGroupResponseModel'];
|
||||
export type Playlist = components['schemas']['PlaylistResponseModel'];
|
||||
export type PlaylistItem = components['schemas']['PlaylistItemResponseModel'];
|
||||
export type PlaylistItemRequest = components['schemas']['PlaylistItemRequest'];
|
||||
export type PlaylistPreviewItem = components['schemas']['PlaylistPreviewItemResponseModel'];
|
||||
export type CreatePlaylistGroupRequest = components['schemas']['CreatePlaylistGroupRequest'];
|
||||
export type UpdatePlaylistGroupRequest = components['schemas']['UpdatePlaylistGroupRequest'];
|
||||
export type CreatePlaylistRequest = components['schemas']['CreatePlaylistRequest'];
|
||||
export type ReplacePlaylistRequest = components['schemas']['ReplacePlaylistRequest'];
|
||||
|
||||
export function getPlaylistGroups(): Promise<PlaylistGroup[]> {
|
||||
return request<PlaylistGroup[]>('/api/playlists/groups');
|
||||
}
|
||||
|
||||
export function createPlaylistGroup(body: CreatePlaylistGroupRequest): Promise<PlaylistGroup> {
|
||||
return request<PlaylistGroup>('/api/playlists/groups', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updatePlaylistGroup(id: number, body: UpdatePlaylistGroupRequest): Promise<PlaylistGroup> {
|
||||
return request<PlaylistGroup>(`/api/playlists/groups/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deletePlaylistGroup(id: number): Promise<void> {
|
||||
return request<void>(`/api/playlists/groups/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function getPlaylists(playlistGroupId: number): Promise<Playlist[]> {
|
||||
return request<Playlist[]>(`/api/playlists?playlistGroupId=${playlistGroupId}`);
|
||||
}
|
||||
|
||||
export function getPlaylistById(id: number): Promise<Playlist> {
|
||||
return request<Playlist>(`/api/playlists/${id}`);
|
||||
}
|
||||
|
||||
export function getPlaylistItems(id: number): Promise<PlaylistItem[]> {
|
||||
return request<PlaylistItem[]>(`/api/playlists/${id}/items`);
|
||||
}
|
||||
|
||||
export function createPlaylist(body: CreatePlaylistRequest): Promise<Playlist> {
|
||||
return request<Playlist>('/api/playlists', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
// PUT = rename + replace the full item list; returns the persisted (re-indexed) items.
|
||||
export function updatePlaylist(id: number, body: ReplacePlaylistRequest): Promise<PlaylistItem[]> {
|
||||
return request<PlaylistItem[]>(`/api/playlists/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deletePlaylist(id: number): Promise<void> {
|
||||
return request<void>(`/api/playlists/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function previewPlaylist(body: ReplacePlaylistRequest): Promise<PlaylistPreviewItem[]> {
|
||||
return request<PlaylistPreviewItem[]>('/api/playlists/preview', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function messageFromPlaylistError(error: unknown, fallback = 'Unable to load playlists'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PlaylistsScreen } from './PlaylistsScreen';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
const groups = [
|
||||
{ id: 1, isSystem: false, name: 'Idents', playlistCount: 1 },
|
||||
{ id: 2, isSystem: true, name: 'Locked', playlistCount: 1 }
|
||||
];
|
||||
|
||||
const playlistsByGroup: Record<number, unknown[]> = {
|
||||
1: [{ id: 10, isSystem: false, name: 'Bumps', playlistGroupId: 1 }],
|
||||
2: [{ id: 20, isSystem: true, name: 'Auto Playlist', playlistGroupId: 2 }]
|
||||
};
|
||||
|
||||
const playlistItems: Record<number, unknown[]> = {
|
||||
10: [
|
||||
{
|
||||
collectionId: 5,
|
||||
collectionName: 'Favorites',
|
||||
collectionType: 'Collection',
|
||||
count: null,
|
||||
id: 100,
|
||||
includeInProgramGuide: true,
|
||||
index: 0,
|
||||
mediaItemId: null,
|
||||
mediaItemName: null,
|
||||
multiCollectionId: null,
|
||||
multiCollectionName: null,
|
||||
playAll: true,
|
||||
playbackOrder: 'Chronological',
|
||||
smartCollectionId: null,
|
||||
smartCollectionName: null
|
||||
},
|
||||
{
|
||||
collectionId: null,
|
||||
collectionName: null,
|
||||
collectionType: 'Movie',
|
||||
count: null,
|
||||
id: 101,
|
||||
includeInProgramGuide: true,
|
||||
index: 1,
|
||||
mediaItemId: 7,
|
||||
mediaItemName: 'Cool Movie',
|
||||
multiCollectionId: null,
|
||||
multiCollectionName: null,
|
||||
playAll: false,
|
||||
playbackOrder: 'None',
|
||||
smartCollectionId: null,
|
||||
smartCollectionName: null
|
||||
}
|
||||
],
|
||||
20: []
|
||||
};
|
||||
|
||||
interface MockOptions {
|
||||
onRequest?: (url: string, method: string, body: unknown) => Response | null;
|
||||
}
|
||||
|
||||
function mockApi(options: MockOptions = {}) {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
|
||||
if (options.onRequest) {
|
||||
const override = options.onRequest(url, method, body);
|
||||
if (override) {
|
||||
return Promise.resolve(override);
|
||||
}
|
||||
}
|
||||
|
||||
if (url === '/api/playlists/groups' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(groups));
|
||||
}
|
||||
|
||||
const groupMatch = /^\/api\/playlists\?playlistGroupId=(\d+)$/.exec(url);
|
||||
if (groupMatch && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(playlistsByGroup[Number(groupMatch[1])] ?? []));
|
||||
}
|
||||
|
||||
const itemsMatch = /^\/api\/playlists\/(\d+)\/items$/.exec(url);
|
||||
if (itemsMatch && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(playlistItems[Number(itemsMatch[1])] ?? []));
|
||||
}
|
||||
|
||||
const byIdMatch = /^\/api\/playlists\/(\d+)$/.exec(url);
|
||||
if (byIdMatch && method === 'GET') {
|
||||
const id = Number(byIdMatch[1]);
|
||||
const found = Object.values(playlistsByGroup)
|
||||
.flat()
|
||||
.find((p) => (p as { id: number }).id === id);
|
||||
return Promise.resolve(jsonResponse(found));
|
||||
}
|
||||
|
||||
if (url === '/api/collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse([{ id: 5, name: 'Favorites', state: 'Normal', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/multi-collections') && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('PlaylistsScreen', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders the group tree with each group and its playlists', async () => {
|
||||
mockApi();
|
||||
render(<PlaylistsScreen />);
|
||||
|
||||
expect(await screen.findByText('Idents')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bumps')).toBeInTheDocument();
|
||||
expect(screen.getByText('Locked')).toBeInTheDocument();
|
||||
expect(screen.getByText('Auto Playlist')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables edit/delete for system groups and playlists', async () => {
|
||||
mockApi();
|
||||
render(<PlaylistsScreen />);
|
||||
|
||||
await screen.findByText('Idents');
|
||||
|
||||
// Groups are sorted alphabetically: Idents (index 0, editable), System (index 1, locked).
|
||||
const deleteGroupButtons = screen.getAllByTitle('Delete group');
|
||||
expect(deleteGroupButtons[0]).not.toBeDisabled();
|
||||
expect(deleteGroupButtons[1]).toBeDisabled();
|
||||
|
||||
// The system playlist's edit + delete are disabled too. DOM order: Idents group first
|
||||
// (Bumps playlist, editable), then Locked group (Auto Playlist, system/locked).
|
||||
const editButtons = screen.getAllByTitle('Edit');
|
||||
expect(editButtons[0]).not.toBeDisabled();
|
||||
expect(editButtons[1]).toBeDisabled();
|
||||
const playlistDeletes = screen.getAllByTitle('Delete');
|
||||
expect(playlistDeletes[0]).not.toBeDisabled();
|
||||
expect(playlistDeletes[playlistDeletes.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('creates a playlist group via the name dialog', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/playlists/groups' && method === 'POST' ? jsonResponse({ id: 3, isSystem: false, name: 'Bumpers', playlistCount: 0 }, 201) : null
|
||||
});
|
||||
|
||||
render(<PlaylistsScreen />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Add group' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('Playlist group name'), { target: { value: 'Bumpers' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const post = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/playlists/groups' && (init?.method ?? '').toUpperCase() === 'POST'
|
||||
);
|
||||
expect(post).toBeDefined();
|
||||
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: 'Bumpers' });
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a playlist and saves the re-indexed item list (PUT replace)', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) => (url === '/api/playlists/10' && method === 'PUT' ? jsonResponse([], 200) : null)
|
||||
});
|
||||
|
||||
render(<PlaylistsScreen />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Bumps'));
|
||||
|
||||
// Editor loaded both items.
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cool Movie')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save playlist' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const put = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/playlists/10' && (init?.method ?? '').toUpperCase() === 'PUT'
|
||||
);
|
||||
expect(put).toBeDefined();
|
||||
const payload = JSON.parse(String(put?.[1]?.body));
|
||||
expect(payload.name).toBe('Bumps');
|
||||
expect(payload.items).toHaveLength(2);
|
||||
expect(payload.items[0]).toMatchObject({ collectionId: 5, collectionType: 'Collection', index: 0 });
|
||||
expect(payload.items[1]).toMatchObject({ collectionType: 'Movie', index: 1, mediaItemId: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the playback-order select for single media-item types', async () => {
|
||||
mockApi();
|
||||
render(<PlaylistsScreen />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Bumps'));
|
||||
|
||||
// Select the Movie item row to open the detail form.
|
||||
fireEvent.click(await screen.findByText('Cool Movie'));
|
||||
|
||||
// Detail-form selects: [0] Collection Type, [1] Selection, [2] Playback Order.
|
||||
const selects = await screen.findAllByRole('combobox');
|
||||
const orderSelect = selects[2];
|
||||
expect(orderSelect).toBeDisabled();
|
||||
expect(within(orderSelect).getAllByRole('option').map((o) => o.textContent)).toEqual(['None']);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user