feat(api): add collections REST endpoints
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Refs #35
This commit is contained in:
2026-06-27 21:43:11 +02:00
parent 8f7d240fc8
commit fb3f2856da
21 changed files with 1250 additions and 69 deletions
@@ -0,0 +1,105 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class CollectionController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/collections")]
[Tags("Collections")]
[EndpointSummary("Get all collections")]
[EndpointGroupName("general")]
public async Task<List<MediaCollectionViewModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllCollections(), cancellationToken);
[HttpGet("/api/collections/{id:int}", Name = "GetCollectionById")]
[Tags("Collections")]
[EndpointSummary("Get a collection by id")]
[EndpointGroupName("general")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> result = await mediator.Send(new GetCollectionById(id), cancellationToken);
return result.ToGetResult();
}
[HttpPost("/api/collections")]
[Tags("Collections")]
[EndpointSummary("Create a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, MediaCollectionViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", vm => vm);
}
[HttpPut("/api/collections/{id:int}")]
[Tags("Collections")]
[EndpointSummary("Update a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateCollectionRequest 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<MediaCollectionViewModel> collection =
await mediator.Send(new GetCollectionById(id), cancellationToken);
return collection.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
None: () => new NotFoundResult());
});
}
[HttpDelete("/api/collections/{id:int}")]
[Tags("Collections")]
[EndpointSummary("Delete a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteCollection(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/collections/{id:int}/items")]
[Tags("Collections")]
[EndpointSummary("Add items to a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> AddItems(
int id,
[Required] [FromBody] AddItemsToCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpDelete("/api/collections/{id:int}/items/{mediaItemId:int}")]
[Tags("Collections")]
[EndpointSummary("Remove an item from a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> RemoveItem(int id, int mediaItemId, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(
new RemoveItemsFromCollection(id)
{
MediaItemIds = [mediaItemId]
},
cancellationToken);
return result.ToDeletedResult();
}
}
@@ -0,0 +1,30 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record AddItemsToCollectionRequest(
List<int> MovieIds,
List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds,
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds,
List<int> RemoteStreamIds)
{
public AddItemsToCollection ToCommand(int collectionId) =>
new(
collectionId,
MovieIds ?? [],
ShowIds ?? [],
SeasonIds ?? [],
EpisodeIds ?? [],
ArtistIds ?? [],
MusicVideoIds ?? [],
OtherVideoIds ?? [],
SongIds ?? [],
ImageIds ?? [],
RemoteStreamIds ?? []);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateCollectionRequest(string Name)
{
public CreateCollection ToCommand() => new(Name);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateSmartCollectionRequest(string Name, string Query)
{
public CreateSmartCollection ToCommand() => new(Query, Name);
}
@@ -0,0 +1,12 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateCollectionRequest(string Name, bool? UseCustomPlaybackOrder)
{
public UpdateCollection ToCommand(int id) =>
new(id, Name)
{
UseCustomPlaybackOrder = Optional(UseCustomPlaybackOrder)
};
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateSmartCollectionRequest(string Name, string Query)
{
public UpdateSmartCollection ToCommand(int id) => new(id, Name, Query);
}
@@ -1,43 +1,80 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class SmartCollectionController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/collections/smart", Name="GetSmartCollections")]
public async Task<List<SmartCollectionResponseModel>> GetAll() =>
await mediator.Send(new GetAllSmartCollectionsForApi());
[HttpGet("/api/smart-collections")]
[Tags("Smart Collections")]
[EndpointSummary("Get all smart collections")]
[EndpointGroupName("general")]
public async Task<List<SmartCollectionResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllSmartCollectionsForApi(), cancellationToken);
[HttpPost("/api/collections/smart/new", Name = "CreateSmartCollection")]
public async Task<IActionResult> AddOne(
[Required] [FromBody]
CreateSmartCollection request)
[HttpGet("/api/smart-collections/{id:int}", Name = "GetSmartCollectionById")]
[Tags("Smart Collections")]
[EndpointSummary("Get a smart collection by id")]
[EndpointGroupName("general")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Either<BaseError, CreateSmartCollectionResult> result =
await mediator.Send(request).MapT(r => new CreateSmartCollectionResult(r.Id));
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
Option<SmartCollectionViewModel> result =
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
return result.ToGetResult();
}
[HttpPut("/api/collections/smart/update", Name="UpdateSmartCollection")]
public async Task<IActionResult> UpdateOne(
[Required] [FromBody]
UpdateSmartCollection request)
[HttpPost("/api/smart-collections")]
[Tags("Smart Collections")]
[EndpointSummary("Create a smart collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateSmartCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, UpdateSmartCollectionResult> result = await mediator.Send(request);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
Either<BaseError, SmartCollectionViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", vm => vm);
}
[HttpDelete("/api/collections/smart/delete/{id:int}", Name="DeleteSmartCollection")]
public async Task<IActionResult> DeleteSmartCollection(int id)
[HttpPut("/api/smart-collections/{id:int}")]
[Tags("Smart Collections")]
[EndpointSummary("Update a smart collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateSmartCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id));
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
Either<BaseError, UpdateSmartCollectionResult> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
Option<SmartCollectionViewModel> smartCollection =
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
return smartCollection.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
None: () => new NotFoundResult());
});
}
[HttpDelete("/api/smart-collections/{id:int}")]
[Tags("Smart Collections")]
[EndpointSummary("Delete a smart collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken);
return result.ToDeletedResult();
}
}