feat(api): REST CRUD for multi-collections and rerun-collections (#151, #152)

Adds MultiCollectionController + RerunCollectionController (list/get/create/update/
delete) over the existing MediatR handlers, with response/request DTOs, controller +
contract tests, regenerated OpenAPI + TS types, and thin SPA api-client modules. SPA
editor screens are a follow-up. Rerun request DTO resolves the chosen entity id into
the minimal VM the handler reads (documented inline).

Refs #151, #152

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 18:18:32 +02:00
co-authored by Claude Opus 4.8
parent a7d3fc3055
commit a496dad9c9
18 changed files with 2178 additions and 3 deletions
@@ -0,0 +1,129 @@
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<PagedMultiCollectionsResponseModel> 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<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MultiCollectionViewModel> result =
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
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<IActionResult> Create(
[Required] [FromBody] CreateMultiCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, MultiCollectionViewModel> 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.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateMultiCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
Option<MultiCollectionViewModel> multiCollection =
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
return multiCollection.Match(
Some: vm => (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<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteMultiCollection(id), cancellationToken);
return result.ToDeletedResult();
}
private static MultiCollectionResponseModel ProjectToResponseModel(MultiCollectionViewModel vm)
{
List<MultiCollectionItemResponseModel> 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);
}
}
@@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record MultiCollectionItemRequest(
int? CollectionId,
int? SmartCollectionId,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
public record CreateMultiCollectionRequest(string Name, List<MultiCollectionItemRequest> Items)
{
public CreateMultiCollection ToCommand() =>
new(
Name,
(Items ?? new List<MultiCollectionItemRequest>())
.Select(i => new CreateMultiCollectionItem(
i.CollectionId,
i.SmartCollectionId,
i.ScheduleAsGroup,
i.PlaybackOrder))
.ToList());
}
@@ -0,0 +1,32 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateRerunCollectionRequest(
string Name,
CollectionType CollectionType,
int SelectedId,
PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder)
{
public CreateRerunCollection ToCommand()
{
(MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem) =
RerunCollectionRequestMapping.ResolveSelection(CollectionType, SelectedId);
return new CreateRerunCollection(
Name,
CollectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
FirstRunPlaybackOrder,
RerunPlaybackOrder);
}
}
@@ -0,0 +1,53 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
// A rerun collection is a tagged union: CollectionType selects which of Collection /
// MultiCollection / SmartCollection / MediaItem is populated. The Create/Update handlers
// read ONLY the `.Id` (or `.MediaItemId`) off the chosen VM — verified in
// CreateRerunCollectionHandler.Validate / UpdateRerunCollectionHandler.ApplyUpdateRequest,
// both of which do `request.Collection?.Id`, `request.MultiCollection?.Id`,
// `request.SmartCollection?.Id`, `request.MediaItem?.MediaItemId` and nothing else. So we
// construct a minimal VM carrying just the selected id (defaults elsewhere) rather than
// doing extra lookup queries. The remaining VMs are left null (the handler expects that).
internal static class RerunCollectionRequestMapping
{
public static (MediaCollectionViewModel Collection,
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem) ResolveSelection(CollectionType collectionType, int selectedId) =>
collectionType switch
{
CollectionType.Collection => (
new MediaCollectionViewModel(
CollectionType.Collection,
selectedId,
string.Empty,
false,
MediaItemState.Normal),
null,
null,
null),
CollectionType.MultiCollection => (
null,
new MultiCollectionViewModel(selectedId, string.Empty, [], []),
null,
null),
CollectionType.SmartCollection => (
null,
null,
new SmartCollectionViewModel(selectedId, string.Empty, string.Empty),
null),
// Any media-item-backed collection type (TelevisionShow, TelevisionSeason,
// Artist, Movie, Episode, MusicVideo, OtherVideo, Song, Image, ...) resolves to
// the MediaItem VM keyed by media item id.
_ => (
null,
null,
null,
new NamedMediaItemViewModel(selectedId, string.Empty))
};
}
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateMultiCollectionRequest(string Name, List<MultiCollectionItemRequest> Items)
{
public UpdateMultiCollection ToCommand(int id) =>
new(
id,
Name,
(Items ?? new List<MultiCollectionItemRequest>())
.Select(i => new UpdateMultiCollectionItem(
i.CollectionId,
i.SmartCollectionId,
i.ScheduleAsGroup,
i.PlaybackOrder))
.ToList());
}
@@ -0,0 +1,33 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateRerunCollectionRequest(
string Name,
CollectionType CollectionType,
int SelectedId,
PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder)
{
public UpdateRerunCollection ToCommand(int id)
{
(MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem) =
RerunCollectionRequestMapping.ResolveSelection(CollectionType, SelectedId);
return new UpdateRerunCollection(
id,
Name,
CollectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
FirstRunPlaybackOrder,
RerunPlaybackOrder);
}
}
@@ -0,0 +1,134 @@
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/rerun-collections", Name = "GetRerunCollections")]
[Tags("Rerun Collections")]
[EndpointSummary("Get all rerun collections (paged)")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedRerunCollectionsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedRerunCollectionsResponseModel> 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/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<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<RerunCollectionViewModel> result =
await mediator.Send(new GetRerunCollectionById(id), cancellationToken);
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpPost("/api/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<IActionResult> Create(
[Required] [FromBody] CreateRerunCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, RerunCollectionViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(
vm => $"/api/rerun-collections/{vm.Id}",
ProjectToResponseModel);
}
[HttpPut("/api/rerun-collections/{id:int}")]
[Tags("Rerun Collections")]
[EndpointSummary("Update a rerun collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(RerunCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateRerunCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
Option<RerunCollectionViewModel> rerunCollection =
await mediator.Send(new GetRerunCollectionById(id), cancellationToken);
return rerunCollection.Match(
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/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<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> 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);
}
}
File diff suppressed because it is too large Load Diff