Merge pull request 'feat: playlist CRUD API + SPA editor (#153)' (#195) from feat/153-playlists into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled

This commit was merged in pull request #195.
This commit is contained in:
2026-07-08 20:09:06 +00:00
22 changed files with 3661 additions and 19 deletions
@@ -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);
}
}
+250 -2
View File
@@ -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
+14 -9
View File
@@ -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
+4 -5
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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} />;
}
+49
View File
@@ -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;
+113 -2
View File
@@ -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');
});
});
+44
View File
@@ -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;
+224
View File
@@ -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