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:
@@ -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
Reference in New Issue
Block a user