using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] public class CollectionController(IMediator mediator) : ControllerBase { [HttpGet("/api/collections")] [Tags("Collections")] [EndpointSummary("Get all collections")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll(CancellationToken cancellationToken) => await mediator.Send(new GetAllCollections(), cancellationToken); [HttpGet("/api/collections/{id:int}", Name = "GetCollectionById")] [Tags("Collections")] [EndpointSummary("Get a collection by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetCollectionById(id), cancellationToken); return result.ToGetResult(); } [HttpGet("/api/collections/{id:int}/items", Name = "GetCollectionItems")] [Tags("Collections")] [EndpointSummary("Get the items in a manual collection")] [EndpointDescription("Returns a manual collection's full contents (all media kinds), paged.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems( int id, [FromQuery] int pageNum = 0, [FromQuery] int pageSize = 100, CancellationToken cancellationToken = default) { int clampedPageNum = Math.Max(0, pageNum); int clampedPageSize = Math.Clamp(pageSize, 1, 100); // The items GET is the reorder editor's load endpoint: emit the collection's version as the // concurrency ETag (issue #253). The response carries children, so read the root separately. Option maybeCollection = await mediator.Send(new GetCollectionById(id), cancellationToken); foreach (MediaCollectionViewModel collection in maybeCollection) { ConcurrencyHeaders.SetETag(Response, collection.Version); } Either result = await mediator.Send( new GetCollectionItems(id, clampedPageNum, clampedPageSize), cancellationToken); return result.ToUpdatedResult(); } [HttpPost("/api/collections")] [Tags("Collections")] [EndpointSummary("Create a collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required] [FromBody] CreateCollectionRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", vm => vm); } [HttpPut("/api/collections/{id:int}")] [Tags("Collections")] [EndpointSummary("Update a collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Update( int id, [Required] [FromBody] UpdateCollectionRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(id), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { Option collection = await mediator.Send(new GetCollectionById(id), cancellationToken); return collection.Match( Some: vm => (IActionResult)new OkObjectResult(vm), None: () => ApiResults.NotFoundProblem()); }); } [HttpPut("/api/collections/{id:int}/custom-order")] [Tags("Collections")] [EndpointSummary("Set a collection's custom playback order")] [EndpointDescription( "Replaces the custom playback order of a manual collection. CustomIndex is assigned from array position " + "(the request body has no index field); ids that are not members of the collection are ignored. Set " + "UseCustomPlaybackOrder on the collection for this order to take effect.")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task UpdateCustomOrder( int id, [Required] [FromBody] UpdateCollectionCustomOrderRequest request, CancellationToken cancellationToken) { IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request); if (ifMatch.Kind is IfMatchKind.Malformed) { return ConcurrencyHeaders.MalformedIfMatchProblem(); } Option maybeCollection = await mediator.Send(new GetCollectionById(id), cancellationToken); if (maybeCollection.IsNone) { return ApiResults.NotFoundProblem(); } Either result = await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { // Emit the new ETag on the bodyless 204 so a same-tab second save doesn't 412 (#253). Option refreshed = await mediator.Send(new GetCollectionById(id), cancellationToken); ConcurrencyHeaders.SetETag(Response, refreshed.Map(c => c.Version).IfNone(0)); return (IActionResult)new NoContentResult(); }); } [HttpDelete("/api/collections/{id:int}")] [Tags("Collections")] [EndpointSummary("Delete a collection")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Delete(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteCollection(id), cancellationToken); return result.ToDeletedResult(); } [HttpPost("/api/collections/{id:int}/items")] [Tags("Collections")] [EndpointSummary("Add items to a collection")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task AddItems( int id, [Required] [FromBody] AddItemsToCollectionRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(id), cancellationToken); return result.ToDeletedResult(); } [HttpDelete("/api/collections/{id:int}/items/{mediaItemId:int}")] [Tags("Collections")] [EndpointSummary("Remove an item from a collection")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task RemoveItem(int id, int mediaItemId, CancellationToken cancellationToken) { Either result = await mediator.Send( new RemoveItemsFromCollection(id) { MediaItemIds = [mediaItemId] }, cancellationToken); return result.ToDeletedResult(); } }