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

This commit was merged in pull request #274.
This commit is contained in:
2026-07-11 21:17:03 +02:00
118 changed files with 4159 additions and 323 deletions
@@ -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());
});
}
+43 -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,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")]
+35 -2
View File
@@ -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());
});
}
+47 -5
View File
@@ -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}")]
+34 -3
View File
@@ -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());
});
}