Merge remote-tracking branch 'origin/main' into feat/91b-blazor-removal
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m8s
This commit was merged in pull request #274.
This commit is contained in:
@@ -49,6 +49,15 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
int clampedPageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
// The items GET is the reorder editor's load endpoint: emit the collection's version as the
|
||||
// concurrency ETag (issue #253). The response carries children, so read the root separately.
|
||||
Option<MediaCollectionViewModel> maybeCollection =
|
||||
await mediator.Send(new GetCollectionById(id), cancellationToken);
|
||||
foreach (MediaCollectionViewModel collection in maybeCollection)
|
||||
{
|
||||
ConcurrencyHeaders.SetETag(Response, collection.Version);
|
||||
}
|
||||
|
||||
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result = await mediator.Send(
|
||||
new GetCollectionItems(id, clampedPageNum, clampedPageSize),
|
||||
cancellationToken);
|
||||
@@ -105,13 +114,21 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
"UseCustomPlaybackOrder on the collection for this order to take effect.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateCustomOrder(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateCollectionCustomOrderRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
|
||||
if (ifMatch.Kind is IfMatchKind.Malformed)
|
||||
{
|
||||
return ConcurrencyHeaders.MalformedIfMatchProblem();
|
||||
}
|
||||
|
||||
Option<MediaCollectionViewModel> maybeCollection =
|
||||
await mediator.Send(new GetCollectionById(id), cancellationToken);
|
||||
if (maybeCollection.IsNone)
|
||||
@@ -119,8 +136,18 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// Emit the new ETag on the bodyless 204 so a same-tab second save doesn't 412 (#253).
|
||||
Option<MediaCollectionViewModel> refreshed =
|
||||
await mediator.Send(new GetCollectionById(id), cancellationToken);
|
||||
ConcurrencyHeaders.SetETag(Response, refreshed.Map(c => c.Version).IfNone(0));
|
||||
return (IActionResult)new NoContentResult();
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/collections/{id:int}")]
|
||||
|
||||
@@ -143,6 +143,9 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/deco-templates/{id:int}/items")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Get deco template items")]
|
||||
[EndpointDescription(
|
||||
"Returns the deco template's items and a strong ETag of the deco template'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<DecoTemplateItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -154,6 +157,9 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The items GET returns children, not the root, so read the deco template's version for the ETag.
|
||||
ConcurrencyHeaders.SetETag(Response, decoTemplate.Map(t => t.Version).IfNone(0));
|
||||
|
||||
List<DecoTemplateItemViewModel> items = await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
|
||||
return new OkObjectResult(
|
||||
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
|
||||
@@ -164,16 +170,32 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
[EndpointSummary("Replace a deco template and its items")]
|
||||
[EndpointDescription(
|
||||
"Replaces the deco template's name and its full item list. Each item assigns a deco to a start/end time " +
|
||||
"of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap.")]
|
||||
"of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap. " +
|
||||
"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(DecoTemplateWithItemsResponseModel), 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> Replace(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceDecoTemplateRequest 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<DecoTemplateViewModel> maybeDecoTemplate =
|
||||
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
|
||||
if (maybeDecoTemplate.IsNone)
|
||||
@@ -184,18 +206,27 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
int decoTemplateGroupId = maybeDecoTemplate.Map(t => t.DecoTemplateGroupId).IfNone(0);
|
||||
|
||||
Either<BaseError, List<DecoTemplateItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(decoTemplateGroupId, id), cancellationToken);
|
||||
await mediator.Send(
|
||||
request.ToCommand(decoTemplateGroupId, id, ifMatch.ExpectedVersion),
|
||||
cancellationToken);
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController).
|
||||
Option<DecoTemplateViewModel> refreshed =
|
||||
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
|
||||
List<DecoTemplateItemViewModel> items =
|
||||
await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items)),
|
||||
Some: vm =>
|
||||
{
|
||||
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -74,14 +81,23 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
[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)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), 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.ExpectedVersion), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
@@ -89,7 +105,12 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
Option<MultiCollectionViewModel> multiCollection =
|
||||
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
|
||||
return multiCollection.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,25 @@ 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 _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController). A None root
|
||||
// (deleted between commit and reload) is a 404, never a 200 without an ETag.
|
||||
Option<PlaylistViewModel> refreshed =
|
||||
await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
List<PlaylistItemViewModel> items = await mediator.Send(new GetPlaylistItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm =>
|
||||
{
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playlists/{id:int}", Name = "DeletePlaylist")]
|
||||
|
||||
@@ -295,6 +295,9 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
}
|
||||
}
|
||||
|
||||
// The GET returns children, so read the playout's version for the concurrency ETag (issue #253).
|
||||
ConcurrencyHeaders.SetETag(Response, maybePlayout.Map(p => p.Version).IfNone(0));
|
||||
|
||||
List<PlayoutAlternateScheduleViewModel> items =
|
||||
await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken);
|
||||
return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
@@ -310,14 +313,22 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
"at least one item, and every ProgramScheduleId must exist. Only valid for Classic playouts.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceAlternateSchedules(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
|
||||
if (ifMatch.Kind is IfMatchKind.Malformed)
|
||||
{
|
||||
return ConcurrencyHeaders.MalformedIfMatchProblem();
|
||||
}
|
||||
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
@@ -358,11 +369,17 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return BaseError.New($"[ProgramScheduleId] {missingScheduleIds[0]} does not exist").ToErrorResult();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// Emit the new ETag (post-bump) so a same-tab second save doesn't 412 against its own write (#253).
|
||||
Option<PlayoutNameViewModel> refreshedPlayout =
|
||||
await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
ConcurrencyHeaders.SetETag(Response, refreshedPlayout.Map(p => p.Version).IfNone(0));
|
||||
|
||||
List<PlayoutAlternateScheduleViewModel> refreshed =
|
||||
await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken);
|
||||
return (IActionResult)new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
@@ -395,6 +412,9 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
}
|
||||
}
|
||||
|
||||
// The GET returns children, so read the playout's version for the concurrency ETag (issue #253).
|
||||
ConcurrencyHeaders.SetETag(Response, maybePlayout.Map(p => p.Version).IfNone(0));
|
||||
|
||||
List<PlayoutTemplateViewModel> items = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken);
|
||||
return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
}
|
||||
@@ -408,14 +428,22 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
"supplied DecoTemplateId must exist. An empty list clears all templates. Only valid for Block playouts.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceTemplates(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
|
||||
if (ifMatch.Kind is IfMatchKind.Malformed)
|
||||
{
|
||||
return ConcurrencyHeaders.MalformedIfMatchProblem();
|
||||
}
|
||||
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
@@ -463,12 +491,17 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
}
|
||||
}
|
||||
|
||||
Option<BaseError> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
Option<BaseError> result =
|
||||
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
foreach (BaseError error in result)
|
||||
{
|
||||
return error.ToErrorResult();
|
||||
}
|
||||
|
||||
// Emit the new ETag (post-bump) so a same-tab second save doesn't 412 against its own write (#253).
|
||||
Option<PlayoutNameViewModel> refreshedPlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
ConcurrencyHeaders.SetETag(Response, refreshedPlayout.Map(p => p.Version).IfNone(0));
|
||||
|
||||
List<PlayoutTemplateViewModel> refreshed = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken);
|
||||
return new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@ namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplaceDecoTemplateRequest(string Name, List<DecoTemplateItemRequest> Items)
|
||||
{
|
||||
public ReplaceDecoTemplateItems ToCommand(int decoTemplateGroupId, int decoTemplateId) =>
|
||||
public ReplaceDecoTemplateItems ToCommand(
|
||||
int decoTemplateGroupId,
|
||||
int decoTemplateId,
|
||||
Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
decoTemplateId,
|
||||
decoTemplateGroupId,
|
||||
Name,
|
||||
(Items ?? []).Select(item => item.ToReplaceItem()).ToList());
|
||||
(Items ?? []).Select(item => item.ToReplaceItem()).ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -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() =>
|
||||
|
||||
@@ -8,10 +8,11 @@ public record ReplacePlayoutAlternateSchedulesRequest(List<PlayoutAlternateSched
|
||||
// is the lowest priority (the catch-all default whose schedule becomes the playout's default
|
||||
// schedule). This mirrors the Blazor editor, which lists items top-to-bottom in priority order
|
||||
// and writes the highest-Index item's schedule as the playout default.
|
||||
public ReplacePlayoutAlternateScheduleItems ToCommand(int playoutId) =>
|
||||
public ReplacePlayoutAlternateScheduleItems ToCommand(int playoutId, Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
playoutId,
|
||||
(Items ?? [])
|
||||
.Select((item, index) => item.ToReplaceItem(index))
|
||||
.ToList());
|
||||
.ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ namespace ErsatzTV.Controllers.Api.Requests;
|
||||
public record ReplacePlayoutTemplatesRequest(List<PlayoutTemplateItemRequest> Items)
|
||||
{
|
||||
// Index is assigned from array order (top-to-bottom priority), mirroring the Blazor editor.
|
||||
public ReplacePlayoutTemplateItems ToCommand(int playoutId) =>
|
||||
public ReplacePlayoutTemplateItems ToCommand(int playoutId, Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
playoutId,
|
||||
(Items ?? [])
|
||||
.Select((item, index) => item.ToReplaceItem(index))
|
||||
.ToList());
|
||||
.ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplaceScheduleItemsRequest(List<ScheduleItemRequest> Items)
|
||||
{
|
||||
public ReplaceProgramScheduleItems ToCommand(int scheduleId) =>
|
||||
public ReplaceProgramScheduleItems ToCommand(int scheduleId, Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
scheduleId,
|
||||
(Items ?? []).Select((item, index) => item.ToReplaceCommand(index)).ToList());
|
||||
(Items ?? []).Select((item, index) => item.ToReplaceCommand(index)).ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplaceTemplateRequest(string Name, List<TemplateItemRequest> Items)
|
||||
{
|
||||
public ReplaceTemplateItems ToCommand(int templateGroupId, int templateId) =>
|
||||
public ReplaceTemplateItems ToCommand(int templateGroupId, int templateId, Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
templateGroupId,
|
||||
templateId,
|
||||
Name,
|
||||
(Items ?? []).Select(item => item.ToReplaceItem()).ToList());
|
||||
(Items ?? []).Select(item => item.ToReplaceItem()).ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record UpdateCollectionCustomOrderRequest(List<int> MediaItemIds)
|
||||
{
|
||||
public UpdateCollectionCustomOrder ToCommand(int collectionId) =>
|
||||
public UpdateCollectionCustomOrder ToCommand(int collectionId, Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
collectionId,
|
||||
(MediaItemIds ?? [])
|
||||
.Select((mediaItemId, index) => new MediaItemCustomOrder(mediaItemId, index))
|
||||
.ToList());
|
||||
.ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record UpdateMultiCollectionRequest(string Name, List<MultiCollectionItemRequest> Items)
|
||||
{
|
||||
public UpdateMultiCollection ToCommand(int id) =>
|
||||
public UpdateMultiCollection ToCommand(int id, Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
id,
|
||||
Name,
|
||||
@@ -16,5 +16,6 @@ public record UpdateMultiCollectionRequest(string Name, List<MultiCollectionItem
|
||||
i.SmartCollectionId,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
.ToList());
|
||||
.ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public record UpdateRerunCollectionRequest(
|
||||
PlaybackOrder FirstRunPlaybackOrder,
|
||||
PlaybackOrder RerunPlaybackOrder)
|
||||
{
|
||||
public UpdateRerunCollection ToCommand(int id)
|
||||
public UpdateRerunCollection ToCommand(int id, Option<int> expectedVersion = default)
|
||||
{
|
||||
(MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
@@ -28,6 +28,7 @@ public record UpdateRerunCollectionRequest(
|
||||
smartCollection,
|
||||
mediaItem,
|
||||
FirstRunPlaybackOrder,
|
||||
RerunPlaybackOrder);
|
||||
RerunPlaybackOrder,
|
||||
expectedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
Option<RerunCollectionViewModel> result =
|
||||
await mediator.Send(new GetRerunCollectionById(id), cancellationToken);
|
||||
|
||||
// This by-id GET is the editor's load endpoint: emit the concurrency ETag (issue #253).
|
||||
foreach (RerunCollectionViewModel vm in result)
|
||||
{
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
}
|
||||
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
@@ -81,13 +88,21 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
[EndpointSummary("Update a rerun collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RerunCollectionResponseModel), 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] UpdateRerunCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
|
||||
if (ifMatch.Kind is IfMatchKind.Malformed)
|
||||
{
|
||||
return ConcurrencyHeaders.MalformedIfMatchProblem();
|
||||
}
|
||||
|
||||
if (!RerunCollectionRequestMapping.IsSupportedSelectionType(request.CollectionType))
|
||||
{
|
||||
return BaseError.New(
|
||||
@@ -95,7 +110,8 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
@@ -103,7 +119,12 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
Option<RerunCollectionViewModel> rerunCollection =
|
||||
await mediator.Send(new GetRerunCollectionById(id), cancellationToken);
|
||||
return rerunCollection.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,7 +104,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
[EndpointDescription(
|
||||
"Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a " +
|
||||
"nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " +
|
||||
"derived from referenced collection/media runtimes and are null when unbounded or unknown.")]
|
||||
"derived from referenced collection/media runtimes and are null when unbounded or unknown. The " +
|
||||
"response also carries a strong ETag of the schedule's version; pass that ETag back as If-Match on " +
|
||||
"the replace (PUT) to detect a concurrent edit (issue #253).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -116,6 +118,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The items GET returns children, not the root, so read the schedule's version for the ETag.
|
||||
ConcurrencyHeaders.SetETag(Response, schedule.Map(s => s.Version).IfNone(0));
|
||||
|
||||
ProgramScheduleItemsWithDurationViewModel items =
|
||||
await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken);
|
||||
return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items));
|
||||
@@ -143,20 +148,57 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
[HttpPut("/api/schedules/{id:int}/items")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Replace schedule items")]
|
||||
[EndpointDescription(
|
||||
"Replaces the schedule's 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<ScheduleItemResponseModel>), 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> ReplaceItems(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceScheduleItemsRequest 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 \"*\"."
|
||||
});
|
||||
}
|
||||
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result
|
||||
.Map(items => items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList())
|
||||
.ToUpdatedResult();
|
||||
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController). A None root
|
||||
// (deleted between commit and reload) is a 404, never a 200 without an ETag.
|
||||
Option<ProgramScheduleViewModel> refreshed =
|
||||
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
|
||||
List<ProgramScheduleItemViewModel> items =
|
||||
await mediator.Send(new GetProgramScheduleItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm =>
|
||||
{
|
||||
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(
|
||||
items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList());
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]
|
||||
|
||||
@@ -134,6 +134,9 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/templates/{id:int}/items")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Get template items")]
|
||||
[EndpointDescription(
|
||||
"Returns the template's items and a strong ETag of the template'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<TemplateItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -145,6 +148,9 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The items GET returns children, not the root, so read the template's version for the ETag.
|
||||
ConcurrencyHeaders.SetETag(Response, template.Map(t => t.Version).IfNone(0));
|
||||
|
||||
List<TemplateItemViewModel> items = await mediator.Send(new GetTemplateItems(id), cancellationToken);
|
||||
return new OkObjectResult(
|
||||
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
|
||||
@@ -155,16 +161,32 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
[EndpointSummary("Replace a template and its items")]
|
||||
[EndpointDescription(
|
||||
"Replaces the template's name and its full item list. Each item assigns a block to a start time of day; " +
|
||||
"items must not overlap (an item's end time is its start time plus the assigned block's duration).")]
|
||||
"items must not overlap (an item's end time is its start time plus the assigned block's duration). " +
|
||||
"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(TemplateWithItemsResponseModel), 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> Replace(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceTemplateRequest 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<TemplateViewModel> maybeTemplate = await mediator.Send(new GetTemplateById(id), cancellationToken);
|
||||
if (maybeTemplate.IsNone)
|
||||
{
|
||||
@@ -174,16 +196,25 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
int templateGroupId = maybeTemplate.Map(t => t.TemplateGroupId).IfNone(0);
|
||||
|
||||
Either<BaseError, List<TemplateItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(templateGroupId, id), cancellationToken);
|
||||
await mediator.Send(request.ToCommand(templateGroupId, id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController). Returning the
|
||||
// handler's item snapshot alongside a separately re-queried version could pair stale items
|
||||
// with a newer ETag — a client would then silently overwrite the interleaving write.
|
||||
Option<TemplateViewModel> refreshed = await mediator.Send(new GetTemplateById(id), cancellationToken);
|
||||
List<TemplateItemViewModel> items = await mediator.Send(new GetTemplateItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items)),
|
||||
Some: vm =>
|
||||
{
|
||||
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user