Files
ersatztv/ErsatzTV/Controllers/Api/MultiCollectionController.cs
T
ef9bba8a52 feat(70): accept per-source weight on the multi-collection API
Without this the weight is only reachable by editing the database, so the
enumerator has nothing to distribute by.

Weight is threaded through create and update (all four handler branches: add and
update, plain and smart) and defaults to 1, so it is optional on the wire and
/api/v1 stays additive under the freeze.

It is returned on the read path too, which is load-bearing rather than symmetry:
the update replaces the item list, so a client that GETs, edits a name, and PUTs
back would silently reset every weight to the default if the GET didn't carry it.

Weight edits ride the existing MultiCollection Version token, so If-Match/412
concurrency needs no new design.

Regenerated v1.json + v1.d.ts + endpoint-index via update-openapi.sh and
generate:api (never hand-edited). The spec picks up weight on both request and
response models and WeightedShuffle in the PlaybackOrder enum; weight is emitted
optional.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00

153 lines
6.5 KiB
C#

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/v1/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/v1/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);
// This by-id GET is the editor's load endpoint: emit the concurrency ETag (issue #253).
foreach (MultiCollectionViewModel vm in result)
{
ConcurrencyHeaders.SetETag(Response, vm.Version);
}
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpPost("/api/v1/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/v1/multi-collections/{vm.Id}",
ProjectToResponseModel);
}
[HttpPut("/api/v1/multi-collections/{id:int}")]
[Tags("Multi Collections")]
[EndpointSummary("Update a multi collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MultiCollectionResponseModel), 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<IActionResult> Update(
int id,
[Required][FromBody] UpdateMultiCollectionRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return ConcurrencyHeaders.MalformedIfMatchProblem();
}
Either<BaseError, Unit> result =
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersions), 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 =>
{
// 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/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,
i.Weight))
.Concat(vm.SmartItems.Map(i => new MultiCollectionItemResponseModel(
null,
i.SmartCollection.Id,
i.SmartCollection.Name,
i.ScheduleAsGroup,
i.PlaybackOrder,
i.Weight)))
.ToList();
return new MultiCollectionResponseModel(vm.Id, vm.Name, items);
}
}