using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.Scheduling; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] public class BlockController(IMediator mediator) : ControllerBase { [HttpGet("/api/blocks/groups", Name = "GetBlockGroups")] [Tags("Blocks")] [EndpointSummary("Get all block groups")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetGroups(CancellationToken cancellationToken) { List groups = await mediator.Send(new GetAllBlockGroups(), cancellationToken); return groups.Map(ProjectToResponseModel).ToList(); } [HttpPost("/api/blocks/groups")] [Tags("Blocks")] [EndpointSummary("Create a block group")] [EndpointGroupName("general")] [ProducesResponseType(typeof(BlockGroupResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task CreateGroup( [Required] [FromBody] CreateBlockGroupRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( vm => $"/api/blocks/groups/{vm.Id}", vm => ProjectToResponseModel(vm)); } [HttpDelete("/api/blocks/groups/{id:int}")] [Tags("Blocks")] [EndpointSummary("Delete a block group")] [EndpointDescription( "Deletes the block group. The database cascade removes every block (and its items) in the group.")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task DeleteGroup(int id, CancellationToken cancellationToken) { List groups = await mediator.Send(new GetAllBlockGroups(), cancellationToken); if (groups.All(g => g.Id != id)) { return ApiResults.NotFoundProblem(); } Option result = await mediator.Send(new DeleteBlockGroup(id), cancellationToken); return result.Match( Some: error => error.ToErrorResult(), None: () => new NoContentResult()); } [HttpGet("/api/blocks")] [Tags("Blocks")] [EndpointSummary("Get all blocks")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll(CancellationToken cancellationToken) { List blocks = await mediator.Send(new GetAllBlocks(), cancellationToken); return blocks.Map(ProjectToResponseModel).ToList(); } [HttpGet("/api/blocks/{id:int}", Name = "GetBlockById")] [Tags("Blocks")] [EndpointSummary("Get a block by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(BlockResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetBlockById(id), cancellationToken); return result.Map(ProjectToResponseModel).ToGetResult(); } [HttpPost("/api/blocks")] [Tags("Blocks")] [EndpointSummary("Create a block")] [EndpointDescription("Creates an empty block in the given block group. The block defaults to 30 minutes.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(BlockResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required] [FromBody] CreateBlockRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( vm => $"/api/blocks/{vm.Id}", vm => ProjectToResponseModel(vm)); } [HttpDelete("/api/blocks/{id:int}")] [Tags("Blocks")] [EndpointSummary("Delete a block")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Delete(int id, CancellationToken cancellationToken) { Option existing = await mediator.Send(new GetBlockById(id), cancellationToken); if (existing.IsNone) { return ApiResults.NotFoundProblem(); } Option result = await mediator.Send(new DeleteBlock(id), cancellationToken); return result.Match( Some: error => error.ToErrorResult(), None: () => new NoContentResult()); } [HttpGet("/api/blocks/{id:int}/items")] [Tags("Blocks")] [EndpointSummary("Get block items")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems(int id, CancellationToken cancellationToken) { Option block = await mediator.Send(new GetBlockById(id), cancellationToken); if (block.IsNone) { return ApiResults.NotFoundProblem(); } List items = await mediator.Send(new GetBlockItems(id), cancellationToken); return new OkObjectResult(items.OrderBy(i => i.Index).Map(ProjectToResponseModel).ToList()); } [HttpPut("/api/blocks/{id:int}")] [Tags("Blocks")] [EndpointSummary("Replace a block and its items")] [EndpointDescription( "Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned " + "from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(BlockWithItemsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Replace( int id, [Required] [FromBody] ReplaceBlockRequest request, CancellationToken cancellationToken) { Option maybeBlock = await mediator.Send(new GetBlockById(id), cancellationToken); if (maybeBlock.IsNone) { return ApiResults.NotFoundProblem(); } int groupId = maybeBlock.Map(b => b.GroupId).IfNone(0); Either result = await mediator.Send(request.ToCommand(groupId, id), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { Option refreshed = await mediator.Send(new GetBlockById(id), cancellationToken); List items = await mediator.Send(new GetBlockItems(id), cancellationToken); return refreshed.Match( Some: vm => (IActionResult)new OkObjectResult( ProjectToWithItemsResponseModel(vm, items)), None: () => ApiResults.NotFoundProblem()); }); } [HttpPost("/api/blocks/{id:int}/preview")] [Tags("Blocks")] [EndpointSummary("Preview a block playout")] [EndpointDescription( "Builds a preview playout from the supplied block definition without persisting any changes to the " + "block, its items, or any playout. Returns the resulting start/finish/title/duration list.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Preview( int id, [Required] [FromBody] ReplaceBlockRequest request, CancellationToken cancellationToken) { Option maybeBlock = await mediator.Send(new GetBlockById(id), cancellationToken); if (maybeBlock.IsNone) { return ApiResults.NotFoundProblem(); } int groupId = maybeBlock.Map(b => b.GroupId).IfNone(0); List preview = await mediator.Send( new PreviewBlockPlayout(request.ToCommand(groupId, id)), cancellationToken); return new OkObjectResult(preview.Map(ProjectToResponseModel).ToList()); } [HttpPost("/api/blocks/{id:int}/copy")] [Tags("Blocks")] [EndpointSummary("Copy a block")] [EndpointDescription("Copies the block and its items into another (or the same) block group under a new name.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(BlockResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Copy( int id, [Required] [FromBody] CopyBlockRequest request, CancellationToken cancellationToken) { Option existing = await mediator.Send(new GetBlockById(id), cancellationToken); if (existing.IsNone) { return ApiResults.NotFoundProblem(); } Either result = await mediator.Send(request.ToCommand(id), cancellationToken); return result.ToCreatedResult( vm => $"/api/blocks/{vm.Id}", vm => ProjectToResponseModel(vm)); } private static BlockGroupResponseModel ProjectToResponseModel(BlockGroupViewModel vm) => new(vm.Id, vm.Name); private static BlockResponseModel ProjectToResponseModel(BlockViewModel vm) => new(vm.Id, vm.GroupId, vm.GroupName, vm.Name, vm.Minutes, vm.StopScheduling); private static BlockWithItemsResponseModel ProjectToWithItemsResponseModel( BlockViewModel vm, List items) => new( vm.Id, vm.GroupId, vm.GroupName, vm.Name, vm.Minutes, vm.StopScheduling, items.OrderBy(i => i.Index).Map(ProjectToResponseModel).ToList()); private static BlockItemResponseModel ProjectToResponseModel(BlockItemViewModel vm) => new( vm.Id, vm.Index, vm.CollectionType, vm.Collection?.Id, vm.Collection?.Name, vm.MultiCollection?.Id, vm.MultiCollection?.Name, vm.SmartCollection?.Id, vm.SmartCollection?.Name, vm.MediaItem?.MediaItemId, vm.MediaItem?.Name, vm.SearchTitle, vm.SearchQuery, vm.PlaybackOrder, vm.IncludeInProgramGuide, vm.DisableWatermarks, vm.Watermarks.Map(w => w.Id).ToList(), vm.GraphicsElements.Map(g => g.Id).ToList()); private static BlockPreviewItemResponseModel ProjectToResponseModel(PlayoutItemPreviewViewModel vm) => new(vm.Title, vm.Start, vm.Finish, vm.Duration); }