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.
This commit is contained in:
2026-07-09 23:56:50 +02:00
parent cfabd6f33f
commit 819e751cab
10 changed files with 646 additions and 6 deletions
@@ -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<IActionResult> AddItems(
int id,
[Required] [FromBody] AddItemsToPlaylistRequest request,
CancellationToken cancellationToken)
{
Option<PlaylistViewModel> 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<BaseError, Unit> 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")]
@@ -0,0 +1,30 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record AddItemsToPlaylistRequest(
List<int> MovieIds,
List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds,
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds,
List<int> RemoteStreamIds)
{
public AddItemsToPlaylist ToCommand(int playlistId) =>
new(
playlistId,
MovieIds ?? [],
ShowIds ?? [],
SeasonIds ?? [],
EpisodeIds ?? [],
ArtistIds ?? [],
MusicVideoIds ?? [],
OtherVideoIds ?? [],
SongIds ?? [],
ImageIds ?? [],
RemoteStreamIds ?? []);
}
@@ -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<IActionResult> 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);
}