feat(api): REST endpoints for blocks, block groups, items, preview + scheduling search pickers
Adds BlockController (block groups + blocks CRUD, items GET, full replace, non-persisting preview) mirroring ScheduleController, plus four scheduling search picker endpoints on SearchController (collections, television shows, television seasons, smart collections). Response DTOs in ErsatzTV.Core/Api/ Scheduling; request DTOs with ToCommand index auto-assignment. #144 S1 (#162) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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<BlockGroupResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<BlockGroupResponseModel>> GetGroups(CancellationToken cancellationToken)
|
||||
{
|
||||
List<BlockGroupViewModel> 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<IActionResult> CreateGroup(
|
||||
[Required] [FromBody] CreateBlockGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, BlockGroupViewModel> 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<IActionResult> DeleteGroup(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
List<BlockGroupViewModel> groups = await mediator.Send(new GetAllBlockGroups(), cancellationToken);
|
||||
if (groups.All(g => g.Id != id))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Option<BaseError> result = await mediator.Send(new DeleteBlockGroup(id), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Some: error => error.ToErrorResult(),
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/blocks")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Get all blocks")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<BlockResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<BlockResponseModel>> GetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
List<BlockViewModel> 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<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BlockViewModel> 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<IActionResult> Create(
|
||||
[Required] [FromBody] CreateBlockRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, BlockViewModel> 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<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BlockViewModel> existing = await mediator.Send(new GetBlockById(id), cancellationToken);
|
||||
if (existing.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Option<BaseError> result = await mediator.Send(new DeleteBlock(id), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Some: error => error.ToErrorResult(),
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/blocks/{id:int}/items")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Get block items")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<BlockItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BlockViewModel> block = await mediator.Send(new GetBlockById(id), cancellationToken);
|
||||
if (block.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<BlockItemViewModel> 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<IActionResult> Replace(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceBlockRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BlockViewModel> maybeBlock = await mediator.Send(new GetBlockById(id), cancellationToken);
|
||||
if (maybeBlock.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
int groupId = maybeBlock.Map(b => b.GroupId).IfNone(0);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToCommand(groupId, id), cancellationToken);
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<BlockViewModel> refreshed = await mediator.Send(new GetBlockById(id), cancellationToken);
|
||||
List<BlockItemViewModel> 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<BlockPreviewItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Preview(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceBlockRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BlockViewModel> maybeBlock = await mediator.Send(new GetBlockById(id), cancellationToken);
|
||||
if (maybeBlock.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
int groupId = maybeBlock.Map(b => b.GroupId).IfNone(0);
|
||||
|
||||
List<PlayoutItemPreviewViewModel> preview = await mediator.Send(
|
||||
new PreviewBlockPlayout(request.ToCommand(groupId, id)),
|
||||
cancellationToken);
|
||||
|
||||
return new OkObjectResult(preview.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
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<BlockItemViewModel> 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);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record BlockItemRequest(
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? MediaItemId,
|
||||
string SearchTitle,
|
||||
string SearchQuery,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
bool IncludeInProgramGuide,
|
||||
bool DisableWatermarks,
|
||||
List<int> WatermarkIds,
|
||||
List<int> GraphicsElementIds)
|
||||
{
|
||||
public ReplaceBlockItem ToReplaceItem(int index) =>
|
||||
new(
|
||||
index,
|
||||
CollectionType,
|
||||
CollectionId,
|
||||
MultiCollectionId,
|
||||
SmartCollectionId,
|
||||
MediaItemId,
|
||||
SearchTitle,
|
||||
SearchQuery,
|
||||
PlaybackOrder,
|
||||
IncludeInProgramGuide,
|
||||
DisableWatermarks,
|
||||
WatermarkIds ?? [],
|
||||
GraphicsElementIds ?? []);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateBlockGroupRequest(string Name)
|
||||
{
|
||||
public CreateBlockGroup ToCommand() => new(Name);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateBlockRequest(int BlockGroupId, string Name)
|
||||
{
|
||||
public CreateBlock ToCommand() => new(BlockGroupId, Name);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplaceBlockRequest(
|
||||
string Name,
|
||||
int Minutes,
|
||||
BlockStopScheduling StopScheduling,
|
||||
List<BlockItemRequest> Items)
|
||||
{
|
||||
public ReplaceBlockItems ToCommand(int blockGroupId, int blockId) =>
|
||||
new(
|
||||
blockGroupId,
|
||||
blockId,
|
||||
Name,
|
||||
Minutes,
|
||||
StopScheduling,
|
||||
(Items ?? []).Select((item, index) => item.ToReplaceItem(index)).ToList());
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Api.Search;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
@@ -35,4 +38,68 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
cancellationToken);
|
||||
return new OkObjectResult(result);
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/collections", Name = "SearchCollections")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search collections by name")]
|
||||
[EndpointDescription("Returns matching collections as {id, name} options for scheduling editors.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<SchedulingPickerOptionResponseModel>> SearchCollections(
|
||||
[FromQuery] string query = "",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<MediaCollectionViewModel> results = await mediator.Send(
|
||||
new SearchCollections(query ?? string.Empty),
|
||||
cancellationToken);
|
||||
return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/television-shows", Name = "SearchTelevisionShows")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search television shows by name")]
|
||||
[EndpointDescription("Returns matching television shows as {id, name} options for scheduling editors.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<SchedulingPickerOptionResponseModel>> SearchTelevisionShows(
|
||||
[FromQuery] string query = "",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<NamedMediaItemViewModel> results = await mediator.Send(
|
||||
new SearchTelevisionShows(query ?? string.Empty),
|
||||
cancellationToken);
|
||||
return results.Map(s => new SchedulingPickerOptionResponseModel(s.MediaItemId, s.Name)).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/television-seasons", Name = "SearchTelevisionSeasons")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search television seasons by name")]
|
||||
[EndpointDescription("Returns matching television seasons as {id, name} options for scheduling editors.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<SchedulingPickerOptionResponseModel>> SearchTelevisionSeasons(
|
||||
[FromQuery] string query = "",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<NamedMediaItemViewModel> results = await mediator.Send(
|
||||
new SearchTelevisionSeasons(query ?? string.Empty),
|
||||
cancellationToken);
|
||||
return results.Map(s => new SchedulingPickerOptionResponseModel(s.MediaItemId, s.Name)).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/smart-collections", Name = "SearchSmartCollections")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search smart collections by name")]
|
||||
[EndpointDescription("Returns matching smart collections as {id, name} options for scheduling editors.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<SchedulingPickerOptionResponseModel>> SearchSmartCollections(
|
||||
[FromQuery] string query = "",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<SmartCollectionViewModel> results = await mediator.Send(
|
||||
new SearchSmartCollections(query ?? string.Empty),
|
||||
cancellationToken);
|
||||
return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user