merge: #253 PR2 Playlist

This commit is contained in:
2026-07-11 18:38:05 +02:00
17 changed files with 447 additions and 37 deletions
+34 -4
View File
@@ -134,6 +134,9 @@ public class PlaylistController(IMediator mediator) : ControllerBase
[HttpGet("/api/playlists/{id:int}/items", Name = "GetPlaylistItems")]
[Tags("Playlists")]
[EndpointSummary("Get the items in a playlist")]
[EndpointDescription(
"Returns the playlist's items and a strong ETag of the playlist's version. Pass that ETag back " +
"as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
@@ -145,6 +148,9 @@ public class PlaylistController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem();
}
// The items GET returns children, not the root, so read the playlist's version for the ETag.
ConcurrencyHeaders.SetETag(Response, maybePlaylist.Map(p => p.Version).IfNone(0));
List<PlaylistItemViewModel> items = await mediator.Send(new GetPlaylistItems(id), cancellationToken);
return new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
}
@@ -168,15 +174,33 @@ public class PlaylistController(IMediator mediator) : ControllerBase
[HttpPut("/api/playlists/{id:int}", Name = "UpdatePlaylist")]
[Tags("Playlists")]
[EndpointSummary("Update a playlist (rename and replace its items)")]
[EndpointDescription(
"Replaces the playlist's name and its full item list. Item indexes are assigned from the array " +
"order. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 " +
"(issue #253); a successful response carries the new ETag.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), 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] ReplacePlaylistRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return new BadRequestObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Invalid If-Match header",
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
});
}
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
if (maybePlaylist.IsNone)
{
@@ -195,10 +219,16 @@ public class PlaylistController(IMediator mediator) : ControllerBase
}
Either<BaseError, List<PlaylistItemViewModel>> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.Match(
Left: error => error.ToErrorResult(),
Right: items => (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList()));
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async items =>
{
Option<PlaylistViewModel> refreshed =
await mediator.Send(new GetPlaylistById(id), cancellationToken);
refreshed.Do(vm => ConcurrencyHeaders.SetETag(Response, vm.Version));
return (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
});
}
[HttpDelete("/api/playlists/{id:int}", Name = "DeletePlaylist")]
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Controllers.Api.Requests;
@@ -56,8 +57,8 @@ public record PlaylistItemRequest(
public record ReplacePlaylistRequest(string? Name, List<PlaylistItemRequest>? Items)
{
public ReplacePlaylistItems ToCommand(int id) =>
new(id, Name ?? string.Empty, BuildItems());
public ReplacePlaylistItems ToCommand(int id, Option<int> expectedVersion = default) =>
new(id, Name ?? string.Empty, BuildItems(), expectedVersion);
// Preview operates on the posted draft, so there is no persisted playlist id (0).
public ReplacePlaylistItems ToReplaceCommand() =>