using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.MediaCollections; 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; namespace ErsatzTV.Controllers.Api; [ApiController] public class MultiCollectionController(IMediator mediator) : ControllerBase { private const int MaxPageSize = 100; [HttpGet("/api/multi-collections", Name = "GetMultiCollections")] [Tags("Multi Collections")] [EndpointSummary("Get all multi collections (paged)")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PagedMultiCollectionsResponseModel), StatusCodes.Status200OK)] public async Task GetAll( [FromQuery] string query = "", [FromQuery] int pageNum = 0, [FromQuery] int pageSize = 100, CancellationToken cancellationToken = default) { int clampedPageNum = Math.Max(0, pageNum); int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); PagedMultiCollectionsViewModel result = await mediator.Send( new GetPagedMultiCollections(query ?? string.Empty, clampedPageNum, clampedPageSize), cancellationToken); return new PagedMultiCollectionsResponseModel( result.TotalCount, result.Page.Map(ProjectToResponseModel).ToList()); } [HttpGet("/api/multi-collections/{id:int}", Name = "GetMultiCollectionById")] [Tags("Multi Collections")] [EndpointSummary("Get a multi collection by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetMultiCollectionById(id), cancellationToken); // This by-id GET is the editor's load endpoint: emit the concurrency ETag (issue #253). foreach (MultiCollectionViewModel vm in result) { ConcurrencyHeaders.SetETag(Response, vm.Version); } return result.Map(ProjectToResponseModel).ToGetResult(); } [HttpPost("/api/multi-collections")] [Tags("Multi Collections")] [EndpointSummary("Create a multi collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required] [FromBody] CreateMultiCollectionRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( vm => $"/api/multi-collections/{vm.Id}", ProjectToResponseModel); } [HttpPut("/api/multi-collections/{id:int}")] [Tags("Multi Collections")] [EndpointSummary("Update a multi collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Update( int id, [Required] [FromBody] UpdateMultiCollectionRequest request, CancellationToken cancellationToken) { IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request); if (ifMatch.Kind is IfMatchKind.Malformed) { return ConcurrencyHeaders.MalformedIfMatchProblem(); } Either result = await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { Option multiCollection = await mediator.Send(new GetMultiCollectionById(id), cancellationToken); return multiCollection.Match( Some: vm => { // Emit the new ETag so a same-tab second save doesn't 412 against its own write (#253). ConcurrencyHeaders.SetETag(Response, vm.Version); return (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)); }, None: () => ApiResults.NotFoundProblem()); }); } [HttpDelete("/api/multi-collections/{id:int}")] [Tags("Multi Collections")] [EndpointSummary("Delete a multi 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 DeleteMultiCollection(id), cancellationToken); return result.ToDeletedResult(); } private static MultiCollectionResponseModel ProjectToResponseModel(MultiCollectionViewModel vm) { List items = vm.Items .Map(i => new MultiCollectionItemResponseModel( i.Collection.Id, null, i.Collection.Name, i.ScheduleAsGroup, i.PlaybackOrder)) .Concat(vm.SmartItems.Map(i => new MultiCollectionItemResponseModel( null, i.SmartCollection.Id, i.SmartCollection.Name, i.ScheduleAsGroup, i.PlaybackOrder))) .ToList(); return new MultiCollectionResponseModel(vm.Id, vm.Name, items); } }