From 7d5850214ea7491541f3de72dde26dbb460e7495 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 8 Jul 2026 20:52:07 +0200 Subject: [PATCH] feat(api): playlist CRUD + item management + preview (#153 backend) Add write endpoints to PlaylistController for the playlist editor: group create/rename/delete, playlist create/read/update/delete, item list read, and draft playout preview. Introduces a RenamePlaylistGroup command/handler (the one missing Application-layer operation) plus request/response DTOs. Endpoints (verb / route / route-Name): - POST /api/playlists/groups CreatePlaylistGroup - PUT /api/playlists/groups/{id} UpdatePlaylistGroup (rename) - DELETE /api/playlists/groups/{id} DeletePlaylistGroup - GET /api/playlists/{id} GetPlaylistById - GET /api/playlists/{id}/items GetPlaylistItems - POST /api/playlists CreatePlaylistInGroup - PUT /api/playlists/{id} UpdatePlaylist (rename + replace items) - DELETE /api/playlists/{id} DeletePlaylist - POST /api/playlists/preview PreviewPlaylist 404-vs-422: unknown-id on GET items / PUT / DELETE returns 404 via a controller-side existence pre-check (mirrors TemplateController.DeleteGroup), leaving existing shared handlers untouched; validation failures return 422. RenamePlaylistGroup returns NotFoundError -> 404 for a missing group. Regenerated wwwroot/openapi/v1.json, docs/endpoint-index.md, and the SPA client types (web/src/api/generated/v1.d.ts). No SPA screen in this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Commands/RenamePlaylistGroup.cs | 6 + .../Commands/RenamePlaylistGroupHandler.cs | 52 + .../PlaylistItemResponseModel.cs | 21 + .../PlaylistPreviewItemResponseModel.cs | 4 + .../OpenApiErrorResponseContractTests.cs | 13 + .../Controllers/PlaylistControllerTests.cs | 464 +++++++++ .../Controllers/Api/PlaylistController.cs | 208 +++- .../Requests/CreatePlaylistGroupRequest.cs | 9 + .../Api/Requests/CreatePlaylistRequest.cs | 9 + .../Api/Requests/ReplacePlaylistRequest.cs | 48 + .../Requests/UpdatePlaylistGroupRequest.cs | 9 + ErsatzTV/wwwroot/openapi/v1.json | 977 ++++++++++++++++++ docs/endpoint-index.md | 11 +- web/src/api/generated/v1.d.ts | 49 + 14 files changed, 1877 insertions(+), 3 deletions(-) create mode 100644 ErsatzTV.Application/MediaCollections/Commands/RenamePlaylistGroup.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/RenamePlaylistGroupHandler.cs create mode 100644 ErsatzTV.Core/Api/MediaCollections/PlaylistItemResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaCollections/PlaylistPreviewItemResponseModel.cs create mode 100644 ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreatePlaylistGroupRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreatePlaylistRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/ReplacePlaylistRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdatePlaylistGroupRequest.cs diff --git a/ErsatzTV.Application/MediaCollections/Commands/RenamePlaylistGroup.cs b/ErsatzTV.Application/MediaCollections/Commands/RenamePlaylistGroup.cs new file mode 100644 index 000000000..36a04b420 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/RenamePlaylistGroup.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.MediaCollections; + +public record RenamePlaylistGroup(int PlaylistGroupId, string Name) + : IRequest>; diff --git a/ErsatzTV.Application/MediaCollections/Commands/RenamePlaylistGroupHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/RenamePlaylistGroupHandler.cs new file mode 100644 index 000000000..f58686b15 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/RenamePlaylistGroupHandler.cs @@ -0,0 +1,52 @@ +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 dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + RenamePlaylistGroup request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation validation = await Validate(dbContext, request, cancellationToken); + return await validation.Apply(playlistGroup => Persist(dbContext, request, playlistGroup)); + } + + private static async Task Persist( + TvContext dbContext, + RenamePlaylistGroup request, + PlaylistGroup playlistGroup) + { + playlistGroup.Name = request.Name; + await dbContext.SaveChangesAsync(); + return Mapper.ProjectToViewModel(playlistGroup); + } + + private static Task> Validate( + TvContext dbContext, + RenamePlaylistGroup request, + CancellationToken cancellationToken) => + PlaylistGroupMustExist(dbContext, request, cancellationToken) + .BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup)); + + private static Task> 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( + new NotFoundError($"PlaylistGroup {request.PlaylistGroupId} does not exist."))); + + private static Validation ValidateName(RenamePlaylistGroup request) => + request.NotEmpty(x => x.Name) + .Bind(_ => request.NotLongerThan(50)(x => x.Name)); +} diff --git a/ErsatzTV.Core/Api/MediaCollections/PlaylistItemResponseModel.cs b/ErsatzTV.Core/Api/MediaCollections/PlaylistItemResponseModel.cs new file mode 100644 index 000000000..93433e38d --- /dev/null +++ b/ErsatzTV.Core/Api/MediaCollections/PlaylistItemResponseModel.cs @@ -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); diff --git a/ErsatzTV.Core/Api/MediaCollections/PlaylistPreviewItemResponseModel.cs b/ErsatzTV.Core/Api/MediaCollections/PlaylistPreviewItemResponseModel.cs new file mode 100644 index 000000000..a70c3cc83 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaCollections/PlaylistPreviewItemResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.MediaCollections; + +public record PlaylistPreviewItemResponseModel(string Title, string Start, string Finish, string Duration); diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 3f2a84ed1..f3f13d547 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -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")] diff --git a/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs b/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs new file mode 100644 index 000000000..4a854b249 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs @@ -0,0 +1,464 @@ +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 ErsatzTV.Core.Errors; +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(); + _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(), Arg.Any()) + .Returns(new List { new(3, "Kids", 2, false) }); + + List 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(), Arg.Any()) + .Returns(Right(new PlaylistGroupViewModel(8, "Kids", 0, false))); + + IActionResult result = await _controller.CreateGroup( + new CreatePlaylistGroupRequest("Kids"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/playlists/groups/8"); + created.Value.ShouldBeOfType().Name.ShouldBe("Kids"); + await _mediator.Received(1).Send( + Arg.Is(c => c.Name == "Kids"), + Arg.Any()); + } + + [Test] + public async Task CreateGroup_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.CreateGroup( + new CreatePlaylistGroupRequest("Kids"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task UpdateGroup_Should_Return_200_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new PlaylistGroupViewModel(4, "New Name", 1, false))); + + IActionResult result = await _controller.UpdateGroup( + 4, + new UpdatePlaylistGroupRequest("New Name"), + CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Name + .ShouldBe("New Name"); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlaylistGroupId == 4 && c.Name == "New Name"), + Arg.Any()); + } + + [Test] + public async Task UpdateGroup_Should_Return_404_On_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.UpdateGroup( + 4, + new UpdatePlaylistGroupRequest("New Name"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task UpdateGroup_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("too long"))); + + IActionResult result = await _controller.UpdateGroup( + 4, + new UpdatePlaylistGroupRequest("New Name"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task DeleteGroup_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(4, "Kids", 0, false) }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlaylistGroupId == 4), + Arg.Any()); + } + + [Test] + public async Task DeleteGroup_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + + IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task DeleteGroup_Should_Return_422_On_System_Group() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(4, "System", 0, true) }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(BaseError.New("Cannot delete system playlist group"))); + + IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new PlaylistViewModel(4, 1, "Kids", false))); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Id.ShouldBe(4); + } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetItems_Should_Return_200_And_Flatten_Names() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new PlaylistViewModel(4, 1, "Kids", false))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + 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().Value.ShouldBeOfType>(); + 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(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetItems(4, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_201_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new PlaylistViewModel(9, 1, "Kids", false))); + + IActionResult result = await _controller.Create( + new CreatePlaylistRequest(1, "Kids"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/playlists/9"); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlaylistGroupId == 1 && c.Name == "Kids"), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("dupe"))); + + IActionResult result = await _controller.Create( + new CreatePlaylistRequest(1, "Kids"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_200_With_Items_And_Map_Request_By_Array_Order() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new PlaylistViewModel(4, 1, "Kids", false))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right>(new List + { + 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 + { + 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().Value.ShouldBeOfType>().Count + .ShouldBe(1); + await _mediator.Received(1).Send( + Arg.Is(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()); + } + + [Test] + public async Task Update_Should_Return_404_When_Playlist_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Update( + 4, + new ReplacePlaylistRequest("Kids", new List()), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new PlaylistViewModel(4, 1, "Kids", false))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left>(BaseError.New("bad item"))); + + IActionResult result = await _controller.Update( + 4, + new ReplacePlaylistRequest("Kids", new List()), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new PlaylistViewModel(4, 1, "Kids", false))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlaylistId == 4), + Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_422_On_System_Playlist() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new PlaylistViewModel(4, 1, "System", true))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(BaseError.New("Cannot delete system (generated) playlist"))); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Preview_Should_Return_200_And_Format_Times() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right>(new List + { + 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 + { + new(0, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true) + }), + CancellationToken.None); + + var items = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + 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(c => c.Data.PlaylistId == 0 && c.Data.Items.Count == 1), + Arg.Any()); + } + + [Test] + public async Task Preview_Should_Return_422_On_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left>(BaseError.New("bad"))); + + IActionResult result = await _controller.Preview( + new ReplacePlaylistRequest("Draft", new List()), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + 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(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV/Controllers/Api/PlaylistController.cs b/ErsatzTV/Controllers/Api/PlaylistController.cs index 2fa92da20..6c8961907 100644 --- a/ErsatzTV/Controllers/Api/PlaylistController.cs +++ b/ErsatzTV/Controllers/Api/PlaylistController.cs @@ -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,64 @@ public class PlaylistController(IMediator mediator) : ControllerBase public async Task> GetGroups(CancellationToken cancellationToken) { List 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 CreateGroup( + [Required] [FromBody] CreatePlaylistGroupRequest request, + CancellationToken cancellationToken) + { + Either 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 UpdateGroup( + int id, + [Required] [FromBody] UpdatePlaylistGroupRequest request, + CancellationToken cancellationToken) + { + Either 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 DeleteGroup(int id, CancellationToken cancellationToken) + { + List groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken); + if (groups.All(g => g.Id != id)) + { + return ApiResults.NotFoundProblem(); + } + + Option result = await mediator.Send(new DeletePlaylistGroup(id), cancellationToken); + return result.Match( + Some: error => error.ToErrorResult(), + None: () => new NoContentResult()); } [HttpGet("/api/playlists", Name = "GetPlaylists")] @@ -32,6 +95,147 @@ public class PlaylistController(IMediator mediator) : ControllerBase { List 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 GetById(int id, CancellationToken cancellationToken) + { + Option 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), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetItems(int id, CancellationToken cancellationToken) + { + Option maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken); + if (maybePlaylist.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List 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 Create( + [Required] [FromBody] CreatePlaylistRequest request, + CancellationToken cancellationToken) + { + Either 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), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Update( + int id, + [Required] [FromBody] ReplacePlaylistRequest request, + CancellationToken cancellationToken) + { + Option maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken); + if (maybePlaylist.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Either> 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 Delete(int id, CancellationToken cancellationToken) + { + Option maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken); + if (maybePlaylist.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Option result = await mediator.Send(new DeletePlaylist(id), cancellationToken); + return result.Match( + 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), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Preview( + [Required] [FromBody] ReplacePlaylistRequest request, + CancellationToken cancellationToken) + { + Either> 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); } diff --git a/ErsatzTV/Controllers/Api/Requests/CreatePlaylistGroupRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreatePlaylistGroupRequest.cs new file mode 100644 index 000000000..b8efb9243 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreatePlaylistGroupRequest.cs @@ -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); +} \ No newline at end of file diff --git a/ErsatzTV/Controllers/Api/Requests/CreatePlaylistRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreatePlaylistRequest.cs new file mode 100644 index 000000000..a324e59c2 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreatePlaylistRequest.cs @@ -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); +} \ No newline at end of file diff --git a/ErsatzTV/Controllers/Api/Requests/ReplacePlaylistRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplacePlaylistRequest.cs new file mode 100644 index 000000000..434a58b28 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplacePlaylistRequest.cs @@ -0,0 +1,48 @@ +#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); +} + +public record ReplacePlaylistRequest(string? Name, List? 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()); + + private List BuildItems() => + (Items ?? new List()) + .Select((item, index) => item.ToReplaceCommand(index)) + .ToList(); +} \ No newline at end of file diff --git a/ErsatzTV/Controllers/Api/Requests/UpdatePlaylistGroupRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdatePlaylistGroupRequest.cs new file mode 100644 index 000000000..48a6ede51 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdatePlaylistGroupRequest.cs @@ -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); +} \ No newline at end of file diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index bc4a96ffb..30a5a5ec2 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -6073,6 +6073,250 @@ } } } + }, + "post": { + "tags": [ + "Playlists" + ], + "summary": "Create a playlist group", + "operationId": "CreatePlaylistGroup", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreatePlaylistGroupRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePlaylistGroupRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreatePlaylistGroupRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreatePlaylistGroupRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlaylistGroupResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistGroupResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistGroupResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/playlists/groups/{id}": { + "put": { + "tags": [ + "Playlists" + ], + "summary": "Rename a playlist group", + "operationId": "UpdatePlaylistGroup", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlaylistGroupRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlaylistGroupRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlaylistGroupRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlaylistGroupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlaylistGroupResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistGroupResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistGroupResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Playlists" + ], + "summary": "Delete a playlist group", + "operationId": "DeletePlaylistGroup", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } } }, "/api/playlists": { @@ -6124,6 +6368,476 @@ } } } + }, + "post": { + "tags": [ + "Playlists" + ], + "summary": "Create a playlist", + "operationId": "CreatePlaylistInGroup", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreatePlaylistRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePlaylistRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreatePlaylistRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreatePlaylistRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlaylistResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/playlists/{id}": { + "get": { + "tags": [ + "Playlists" + ], + "summary": "Get a playlist by id", + "operationId": "GetPlaylistById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlaylistResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "tags": [ + "Playlists" + ], + "summary": "Update a playlist (rename and replace its items)", + "operationId": "UpdatePlaylist", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlaylistRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlaylistRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlaylistRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlaylistRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistItemResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistItemResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistItemResponseModel" + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Playlists" + ], + "summary": "Delete a playlist", + "operationId": "DeletePlaylist", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/playlists/{id}/items": { + "get": { + "tags": [ + "Playlists" + ], + "summary": "Get the items in a playlist", + "operationId": "GetPlaylistItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistItemResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistItemResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistItemResponseModel" + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/playlists/preview": { + "post": { + "tags": [ + "Playlists" + ], + "summary": "Preview the playout of a draft playlist", + "description": "Builds a preview playout from the posted draft playlist items (no persistence).", + "operationId": "PreviewPlaylist", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlaylistRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlaylistRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlaylistRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePlaylistRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistPreviewItemResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistPreviewItemResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistPreviewItemResponseModel" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } } }, "/api/playouts": { @@ -14949,6 +15663,39 @@ } } }, + "CreatePlaylistGroupRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + } + } + }, + "CreatePlaylistRequest": { + "required": [ + "playlistGroupId", + "name" + ], + "type": "object", + "properties": { + "playlistGroupId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + } + } + }, "CreatePlayoutRequest": { "required": [ "channelId", @@ -17639,6 +18386,198 @@ } } }, + "PlaylistItemRequest": { + "required": [ + "index", + "collectionType", + "collectionId", + "multiCollectionId", + "smartCollectionId", + "mediaItemId", + "playbackOrder", + "count", + "playAll", + "includeInProgramGuide" + ], + "type": "object", + "properties": { + "index": { + "type": "integer", + "format": "int32" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "count": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playAll": { + "type": "boolean" + }, + "includeInProgramGuide": { + "type": "boolean" + } + } + }, + "PlaylistItemResponseModel": { + "required": [ + "id", + "index", + "collectionType", + "collectionId", + "collectionName", + "multiCollectionId", + "multiCollectionName", + "smartCollectionId", + "smartCollectionName", + "mediaItemId", + "mediaItemName", + "playbackOrder", + "count", + "playAll", + "includeInProgramGuide" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "index": { + "type": "integer", + "format": "int32" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "collectionName": { + "type": [ + "null", + "string" + ] + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionName": { + "type": [ + "null", + "string" + ] + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionName": { + "type": [ + "null", + "string" + ] + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemName": { + "type": [ + "null", + "string" + ] + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "count": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playAll": { + "type": "boolean" + }, + "includeInProgramGuide": { + "type": "boolean" + } + } + }, + "PlaylistPreviewItemResponseModel": { + "required": [ + "title", + "start", + "finish", + "duration" + ], + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "start": { + "type": "string" + }, + "finish": { + "type": "string" + }, + "duration": { + "type": "string" + } + } + }, "PlaylistResponseModel": { "required": [ "id", @@ -18850,6 +19789,30 @@ } } }, + "ReplacePlaylistRequest": { + "required": [ + "name", + "items" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/PlaylistItemRequest" + } + } + } + }, "ReplacePlayoutAlternateSchedulesRequest": { "required": [ "items" @@ -20694,6 +21657,20 @@ } } }, + "UpdatePlaylistGroupRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + } + } + }, "UpdatePlayoutDetailsRequest": { "required": [ "dailyRebuildTime", diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 6659ff8f2..7fe213156 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -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 diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index d2ec8688b..1e330a8e0 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -436,6 +436,13 @@ export interface components { "CreateMultiCollectionRequest": { "name": null | string; "items": null | Array; + }; + "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; + }; + "ReplacePlaylistRequest": { + "name": null | string; + "items": null | Array; }; "ReplacePlayoutAlternateSchedulesRequest": { "items": null | Array; @@ -1518,6 +1564,9 @@ export interface components { "UpdateMultiCollectionRequest": { "name": null | string; "items": null | Array; + }; + "UpdatePlaylistGroupRequest": { + "name": null | string; }; "UpdatePlayoutDetailsRequest": { "dailyRebuildTime": null | string;