From 819e751cab7d62ec7af86c38454ea4f3b4115e50 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 23:56:50 +0200 Subject: [PATCH] feat(api): playlist add-items + search all-items endpoints (#208 #209) - POST /api/playlists/{id:int}/items wraps the existing AddItemsToPlaylist command (mirrors CollectionController.AddItems); controller pre-checks playlist existence for a real 404, and the handler now rejects adds to system (generated) playlists, matching the guard already applied to rename/delete/replace-items so the Blazor path gets the same protection. - GET /api/search/all-items wraps the existing QuerySearchIndexAllItems query, returning a new SearchResultAllItemsResponseModel (never expose the VM directly) so the SPA's shared "add all to collection/playlist" component can materialize ids before calling the add endpoints, same two-step flow Blazor's Search.razor already uses. - Show-detail DTO check: ShowDetailResponseModel already exposes libraryId, title, and mediaSourceKind (serialized as a string enum via the global StringEnumConverter) - no changes needed. Adds controller tests (route table + per-action) for both endpoints and regenerates the OpenAPI document, endpoint index, and SPA client types. --- .../Commands/AddItemsToPlaylistHandler.cs | 16 +- .../SearchResultAllItemsResponseModel.cs | 13 + .../Controllers/PlaylistControllerTests.cs | 71 ++++ .../Controllers/SearchControllerTests.cs | 48 +++ .../Controllers/Api/PlaylistController.cs | 25 ++ .../Api/Requests/AddItemsToPlaylistRequest.cs | 30 ++ ErsatzTV/Controllers/Api/SearchController.cs | 37 ++ ErsatzTV/wwwroot/openapi/v1.json | 384 ++++++++++++++++++ docs/endpoint-index.md | 4 +- web/src/api/generated/v1.d.ts | 24 ++ 10 files changed, 646 insertions(+), 6 deletions(-) create mode 100644 ErsatzTV.Core/Api/Search/SearchResultAllItemsResponseModel.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/AddItemsToPlaylistRequest.cs diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs index b999a0543..98251b91a 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs @@ -84,14 +84,20 @@ public class AddItemsToPlaylistHandler : IRequestHandler collection); - private static Task> PlaylistMustExist( + private static async Task> PlaylistMustExist( TvContext dbContext, AddItemsToPlaylist request, - CancellationToken cancellationToken) => - dbContext.Playlists + CancellationToken cancellationToken) + { + Option maybePlaylist = await dbContext.Playlists .Include(c => c.Items) - .SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken) - .Map(o => o.ToValidation("Playlist does not exist.")); + .SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken); + + return maybePlaylist.ToValidation("Playlist does not exist.") + .Bind(playlist => playlist.IsSystem + ? BaseError.New("Cannot add items to system (generated) playlist") + : Success(playlist)); + } private Task> ValidateMovies(AddItemsToPlaylist request) => _movieRepository.AllMoviesExist(request.MovieIds) diff --git a/ErsatzTV.Core/Api/Search/SearchResultAllItemsResponseModel.cs b/ErsatzTV.Core/Api/Search/SearchResultAllItemsResponseModel.cs new file mode 100644 index 000000000..f94d0897d --- /dev/null +++ b/ErsatzTV.Core/Api/Search/SearchResultAllItemsResponseModel.cs @@ -0,0 +1,13 @@ +namespace ErsatzTV.Core.Api.Search; + +public record SearchResultAllItemsResponseModel( + List MovieIds, + List ShowIds, + List SeasonIds, + List EpisodeIds, + List ArtistIds, + List MusicVideoIds, + List OtherVideoIds, + List SongIds, + List ImageIds, + List RemoteStreamIds); diff --git a/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs b/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs index d75763304..07dc7a086 100644 --- a/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs @@ -15,6 +15,7 @@ using NSubstitute; using NUnit.Framework; using Shouldly; using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; namespace ErsatzTV.Tests.Controllers; @@ -44,6 +45,7 @@ public class PlaylistControllerTests 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.AddItems), "POST", "/api/playlists/{id:int}/items"); ShouldHaveActionRoute(nameof(PlaylistController.Preview), "POST", "/api/playlists/preview"); } @@ -445,6 +447,75 @@ public class PlaylistControllerTests result.ShouldBeOfType(); } + [Test] + public async Task AddItems_Should_Return_204_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new PlaylistViewModel(4, 1, "Kids", false))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.AddItems( + 4, + new AddItemsToPlaylistRequest([1, 2], [3], [], [], [], [], [], [], [], []), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => + c.PlaylistId == 4 && + c.MovieIds.SequenceEqual(new List { 1, 2 }) && + c.ShowIds.SequenceEqual(new List { 3 })), + Arg.Any()); + } + + [Test] + public async Task AddItems_Should_Return_404_When_Playlist_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.AddItems( + 4, + new AddItemsToPlaylistRequest([1], [], [], [], [], [], [], [], [], []), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task AddItems_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(Left(BaseError.New("Cannot add items to system (generated) playlist"))); + + IActionResult result = await _controller.AddItems( + 4, + new AddItemsToPlaylistRequest([1], [], [], [], [], [], [], [], [], []), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task AddItems_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("Movie does not exist"))); + + IActionResult result = await _controller.AddItems( + 4, + new AddItemsToPlaylistRequest([999], [], [], [], [], [], [], [], [], []), + CancellationToken.None); + + result.ShouldBeOfType(); + } + [Test] public async Task Preview_Should_Return_200_And_Format_Times() { diff --git a/ErsatzTV.Tests/Controllers/SearchControllerTests.cs b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs index e7a7331ff..184fb88ed 100644 --- a/ErsatzTV.Tests/Controllers/SearchControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs @@ -75,6 +75,54 @@ public class SearchControllerTests ok.Value.ShouldBe(results); } + [Test] + public void Controller_Should_Expose_SearchAllItems_Route_With_Stable_Operation_Name() + { + MethodInfo action = typeof(SearchController).GetMethod(nameof(SearchController.SearchAllItems)) + ?? throw new AssertionException($"Missing action {nameof(SearchController.SearchAllItems)}"); + + var attribute = action.GetCustomAttributes().Single(); + attribute.Template.ShouldBe("/api/search/all-items"); + attribute.Name.ShouldBe("SearchAllItems"); + } + + [Test] + public async Task SearchAllItems_Should_Return_422_For_Empty_Query() + { + IActionResult result = await _controller.SearchAllItems(" ", CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + unprocessable.StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SearchAllItems_Should_Map_Id_Lists() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new SearchResultAllItemsViewModel( + [1, 2], + [3], + [], + [], + [], + [], + [], + [], + [], + [])); + + IActionResult result = await _controller.SearchAllItems("star", CancellationToken.None); + + var body = result.ShouldBeOfType().Value + .ShouldBeOfType(); + body.MovieIds.ShouldBe(new List { 1, 2 }); + body.ShowIds.ShouldBe(new List { 3 }); + await _mediator.Received(1).Send( + Arg.Is(q => q.Query == "star"), + Arg.Any()); + } + [Test] public async Task SearchCollections_Should_Map_To_Picker_Options() { diff --git a/ErsatzTV/Controllers/Api/PlaylistController.cs b/ErsatzTV/Controllers/Api/PlaylistController.cs index 8876105cb..85055617d 100644 --- a/ErsatzTV/Controllers/Api/PlaylistController.cs +++ b/ErsatzTV/Controllers/Api/PlaylistController.cs @@ -222,6 +222,31 @@ public class PlaylistController(IMediator mediator) : ControllerBase None: () => new NoContentResult()); } + [HttpPost("/api/playlists/{id:int}/items", Name = "AddItemsToPlaylist")] + [Tags("Playlists")] + [EndpointSummary("Add items to a playlist")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task AddItems( + int id, + [Required] [FromBody] AddItemsToPlaylistRequest request, + CancellationToken cancellationToken) + { + Option maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken); + if (maybePlaylist.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + // System (generated) playlists must not have items added. The AddItemsToPlaylistHandler + // also enforces this (defense-in-depth for the Blazor path, which calls the same command + // from MultiSelectBase.AddItemsToPlaylist). + Either result = await mediator.Send(request.ToCommand(id), cancellationToken); + return result.ToDeletedResult(); + } + [HttpPost("/api/playlists/preview", Name = "PreviewPlaylist")] [Tags("Playlists")] [EndpointSummary("Preview the playout of a draft playlist")] diff --git a/ErsatzTV/Controllers/Api/Requests/AddItemsToPlaylistRequest.cs b/ErsatzTV/Controllers/Api/Requests/AddItemsToPlaylistRequest.cs new file mode 100644 index 000000000..d8d581f59 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/AddItemsToPlaylistRequest.cs @@ -0,0 +1,30 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record AddItemsToPlaylistRequest( + List MovieIds, + List ShowIds, + List SeasonIds, + List EpisodeIds, + List ArtistIds, + List MusicVideoIds, + List OtherVideoIds, + List SongIds, + List ImageIds, + List RemoteStreamIds) +{ + public AddItemsToPlaylist ToCommand(int playlistId) => + new( + playlistId, + MovieIds ?? [], + ShowIds ?? [], + SeasonIds ?? [], + EpisodeIds ?? [], + ArtistIds ?? [], + MusicVideoIds ?? [], + OtherVideoIds ?? [], + SongIds ?? [], + ImageIds ?? [], + RemoteStreamIds ?? []); +} diff --git a/ErsatzTV/Controllers/Api/SearchController.cs b/ErsatzTV/Controllers/Api/SearchController.cs index c32691453..b544522c7 100644 --- a/ErsatzTV/Controllers/Api/SearchController.cs +++ b/ErsatzTV/Controllers/Api/SearchController.cs @@ -39,6 +39,30 @@ public class SearchController(IMediator mediator) : ControllerBase return new OkObjectResult(result); } + [HttpGet("/api/search/all-items", Name = "SearchAllItems")] + [Tags("Search")] + [EndpointSummary("Search library items across all media kinds and return raw id lists")] + [EndpointDescription( + "Returns every matching item's id, grouped by media kind, with no paging. Used by the SPA's " + + "\"add all to collection/playlist\" flow to materialize ids before calling the add endpoints.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(SearchResultAllItemsResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task SearchAllItems( + [FromQuery] string query = "", + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + { + return BaseError.New("A non-empty query is required").ToErrorResult(); + } + + SearchResultAllItemsViewModel result = await mediator.Send( + new QuerySearchIndexAllItems(query), + cancellationToken); + return new OkObjectResult(Project(result)); + } + [HttpGet("/api/search/collections", Name = "SearchCollections")] [Tags("Search")] [EndpointSummary("Search collections by name")] @@ -134,4 +158,17 @@ public class SearchController(IMediator mediator) : ControllerBase cancellationToken); return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList(); } + + private static SearchResultAllItemsResponseModel Project(SearchResultAllItemsViewModel vm) => + new( + vm.MovieIds, + vm.ShowIds, + vm.SeasonIds, + vm.EpisodeIds, + vm.ArtistIds, + vm.MusicVideoIds, + vm.OtherVideoIds, + vm.SongIds, + vm.ImageIds, + vm.RemoteStreamIds); } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index cb5b2da7b..847e90c5a 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -6852,6 +6852,94 @@ } } } + }, + "post": { + "tags": [ + "Playlists" + ], + "summary": "Add items to a playlist", + "operationId": "AddItemsToPlaylist", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/AddItemsToPlaylistRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddItemsToPlaylistRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/AddItemsToPlaylistRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/AddItemsToPlaylistRequest" + } + } + }, + "required": true + }, + "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/preview": { @@ -9838,6 +9926,68 @@ } } }, + "/api/search/all-items": { + "get": { + "tags": [ + "Search" + ], + "summary": "Search library items across all media kinds and return raw id lists", + "description": "Returns every matching item's id, grouped by media kind, with no paging. Used by the SPA's \"add all to collection/playlist\" flow to materialize ids before calling the add endpoints.", + "operationId": "SearchAllItems", + "parameters": [ + { + "name": "query", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/SearchResultAllItemsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResultAllItemsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SearchResultAllItemsResponseModel" + } + } + } + }, + "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/search/collections": { "get": { "tags": [ @@ -13845,6 +13995,123 @@ } } }, + "AddItemsToPlaylistRequest": { + "required": [ + "movieIds", + "showIds", + "seasonIds", + "episodeIds", + "artistIds", + "musicVideoIds", + "otherVideoIds", + "songIds", + "imageIds", + "remoteStreamIds" + ], + "type": "object", + "properties": { + "movieIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "showIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "seasonIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "episodeIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "artistIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "musicVideoIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "otherVideoIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "songIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "imageIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "remoteStreamIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, "AddTraktListRequest": { "required": [ "url" @@ -20819,6 +21086,123 @@ } } }, + "SearchResultAllItemsResponseModel": { + "required": [ + "movieIds", + "showIds", + "seasonIds", + "episodeIds", + "artistIds", + "musicVideoIds", + "otherVideoIds", + "songIds", + "imageIds", + "remoteStreamIds" + ], + "type": "object", + "properties": { + "movieIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "showIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "seasonIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "episodeIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "artistIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "musicVideoIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "otherVideoIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "songIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "imageIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + }, + "remoteStreamIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, "SearchResultGroupResponseModel": { "required": [ "totalCount", diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 4a9d994fe..1428189cb 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`.* -129 endpoints, 202 operations. +130 endpoints, 204 operations. ## Artists @@ -214,6 +214,7 @@ | 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 | +| POST | `/api/playlists/{id}/items` | AddItemsToPlaylist | Add items to a playlist | ## Playouts @@ -274,6 +275,7 @@ | Method | Path | Operation | Summary | |---|---|---|---| | GET | `/api/search` | Search | Search library items across all media kinds | +| GET | `/api/search/all-items` | SearchAllItems | Search library items across all media kinds and return raw id lists | | GET | `/api/search/artists` | SearchArtists | Search artists by name | | GET | `/api/search/collections` | SearchCollections | Search collections by name | | GET | `/api/search/multi-collections` | SearchMultiCollections | Search multi collections by name | diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 81d187617..9c9024447 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -20,6 +20,18 @@ export interface components { "songIds": null | Array; "imageIds": null | Array; "remoteStreamIds": null | Array; + }; + "AddItemsToPlaylistRequest": { + "movieIds": null | Array; + "showIds": null | Array; + "seasonIds": null | Array; + "episodeIds": null | Array; + "artistIds": null | Array; + "musicVideoIds": null | Array; + "otherVideoIds": null | Array; + "songIds": null | Array; + "imageIds": null | Array; + "remoteStreamIds": null | Array; }; "AddTraktListRequest": { "url": null | string; @@ -1313,6 +1325,18 @@ export interface components { "SchedulingPickerOptionResponseModel": { "id": number; "name": string; + }; + "SearchResultAllItemsResponseModel": { + "movieIds": null | Array; + "showIds": null | Array; + "seasonIds": null | Array; + "episodeIds": null | Array; + "artistIds": null | Array; + "musicVideoIds": null | Array; + "otherVideoIds": null | Array; + "songIds": null | Array; + "imageIds": null | Array; + "remoteStreamIds": null | Array; }; "SearchResultGroupResponseModel": { "totalCount": number;