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 RerunCollectionController(IMediator mediator) : ControllerBase { private const int MaxPageSize = 100; [HttpGet("/api/v1/rerun-collections", Name = "GetRerunCollections")] [Tags("Rerun Collections")] [EndpointSummary("Get all rerun collections (paged)")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PagedRerunCollectionsResponseModel), 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); PagedRerunCollectionsViewModel result = await mediator.Send( new GetPagedRerunCollections(query ?? string.Empty, clampedPageNum, clampedPageSize), cancellationToken); return new PagedRerunCollectionsResponseModel( result.TotalCount, result.Page.Map(ProjectToResponseModel).ToList()); } [HttpGet("/api/v1/rerun-collections/{id:int}", Name = "GetRerunCollectionById")] [Tags("Rerun Collections")] [EndpointSummary("Get a rerun collection by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetRerunCollectionById(id), cancellationToken); // This by-id GET is the editor's load endpoint: emit the concurrency ETag (issue #253). foreach (RerunCollectionViewModel vm in result) { ConcurrencyHeaders.SetETag(Response, vm.Version); } return result.Map(ProjectToResponseModel).ToGetResult(); } [HttpPost("/api/v1/rerun-collections")] [Tags("Rerun Collections")] [EndpointSummary("Create a rerun collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required][FromBody] CreateRerunCollectionRequest request, CancellationToken cancellationToken) { if (!RerunCollectionRequestMapping.IsSupportedSelectionType(request.CollectionType)) { return BaseError.New( $"Unsupported collection type '{request.CollectionType}' for a rerun collection") .ToErrorResult(); } Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( vm => $"/api/v1/rerun-collections/{vm.Id}", ProjectToResponseModel); } [HttpPut("/api/v1/rerun-collections/{id:int}")] [Tags("Rerun Collections")] [EndpointSummary("Update a rerun collection")] [EndpointGroupName("general")] [ProducesResponseType(typeof(RerunCollectionResponseModel), 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] UpdateRerunCollectionRequest request, CancellationToken cancellationToken) { IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request); if (ifMatch.Kind is IfMatchKind.Malformed) { return ConcurrencyHeaders.MalformedIfMatchProblem(); } if (!RerunCollectionRequestMapping.IsSupportedSelectionType(request.CollectionType)) { return BaseError.New( $"Unsupported collection type '{request.CollectionType}' for a rerun collection") .ToErrorResult(); } Either result = await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersions), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { Option rerunCollection = await mediator.Send(new GetRerunCollectionById(id), cancellationToken); return rerunCollection.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/v1/rerun-collections/{id:int}")] [Tags("Rerun Collections")] [EndpointSummary("Delete a rerun 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 DeleteRerunCollection(id), cancellationToken); return result.ToDeletedResult(); } // A rerun collection is a tagged union keyed by CollectionType; exactly one of // Collection / MultiCollection / SmartCollection / MediaItem is populated on the VM. // Flatten it to a single selected id + name for the SPA. private static RerunCollectionResponseModel ProjectToResponseModel(RerunCollectionViewModel vm) { int? selectedId = vm.Collection?.Id ?? vm.MultiCollection?.Id ?? vm.SmartCollection?.Id ?? vm.MediaItem?.MediaItemId; string selectedName = vm.Collection?.Name ?? vm.MultiCollection?.Name ?? vm.SmartCollection?.Name ?? vm.MediaItem?.Name; return new RerunCollectionResponseModel( vm.Id, vm.Name, vm.CollectionType, selectedId, selectedName, vm.FirstRunPlaybackOrder, vm.RerunPlaybackOrder); } }