Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adds the shared optimistic-concurrency contract so a stale second tab can no longer silently overwrite a fresher edit. PR1 lands the infra + the Block reference aggregate; PRs 2–4 fan the same recipe across the other 8 roots (design: #253#issuecomment-8472). Contract - `IVersionedAggregate` (`int Version`) on all 9 replace-all roots (ProgramSchedule, Block, Template, DecoTemplate, Playlist, Collection, Playout, MultiCollection, RerunCollection), EF-mapped `.IsConcurrencyToken()`; one dual-provider migration `AddAggregateVersions` (nullable:false, default 0). - Strong `ETag` of `Version` on the aggregate GET; `If-Match` on the PUT; mismatch → 412 (distinct from the §3a 409 build-lock guard). Successful PUT returns the new ETag. - `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`; `ConcurrencyHeaders.ParseIfMatch/SetETag`; malformed If-Match → 400; `*`/absent = Phase-1 force-write. Block reference wiring - Handler: standalone `Either` via `CheckVersion` AFTER validation (never through `Apply`, which Join()-flattens the subtype to 422), unconditional `Version++`, `SaveChangesWithConcurrencyGuard` backstop (DbUpdateConcurrencyException → 412). - `BlockViewModel.Version` (header-only, not echoed in the body); controller sets the ETag on GET items and on the successful PUT. - SPA: `client.requestWithMeta` seam; `blocks.getBlockItemsWithMeta` + `replaceBlock` If-Match/ETag round-trip; `BlockEditor` holds the ETag, sends If-Match, and on 412 opens a blocking "changed elsewhere — reload" dialog. Tests - Handler contract tests: stale-If-Match → 412 (no mutation), matching/absent → success + bump, no-op save still bumps, and a two-context racing save → 412; proven non-vacuous (drop `.IsConcurrencyToken()` → the race test fails). - Controller tests: malformed If-Match → 400, If-Match threaded to the command, ETag on GET/PUT, 412 passthrough. SPA: requestWithMeta ETag, replaceBlock If-Match, 412 dialog. Docs: api-conventions §7a, spa-conventions §4a, domain-model glossary, decisions log. Refs #253 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
8.9 KiB
C#
221 lines
8.9 KiB
C#
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Scheduling;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Extensions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ErsatzTV.Application.Scheduling;
|
|
|
|
public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
|
: IRequestHandler<ReplaceBlockItems, Either<BaseError, Unit>>
|
|
{
|
|
public async Task<Either<BaseError, Unit>> Handle(
|
|
ReplaceBlockItems request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
Validation<BaseError, Block> validation = await Validate(dbContext, request, cancellationToken);
|
|
|
|
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
|
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
|
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
|
// LanguageExtensions.ToEither joins the Seq<BaseError> to a single BaseError (the native
|
|
// Validation.ToEither() would keep Seq and is called explicitly here to avoid that shadow).
|
|
Either<BaseError, Block> validated = LanguageExtensions.ToEither(validation)
|
|
.Bind(block => block.CheckVersion(request.ExpectedVersion));
|
|
|
|
return await validated.Match(
|
|
Right: block => Persist(dbContext, request, block, cancellationToken),
|
|
Left: error => Task.FromResult<Either<BaseError, Unit>>(error));
|
|
}
|
|
|
|
private static async Task<Either<BaseError, Unit>> Persist(
|
|
TvContext dbContext,
|
|
ReplaceBlockItems request,
|
|
Block block,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
block.Name = request.Name;
|
|
block.Minutes = request.Minutes;
|
|
block.StopScheduling = request.StopScheduling;
|
|
block.DateUpdated = DateTime.UtcNow;
|
|
|
|
dbContext.RemoveRange(block.Items);
|
|
block.Items = request.Items.Map(i => BuildItem(block, i.Index, i)).ToList();
|
|
|
|
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
|
|
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
|
|
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
|
|
block.Version++;
|
|
|
|
// TODO: refresh any playouts that use this schedule
|
|
// foreach (Playout playout in programSchedule.Playouts)
|
|
// {
|
|
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
|
|
// }
|
|
|
|
// Save through the guard so an EF concurrency failure (a racing writer won between our load
|
|
// and save) maps to 412 rather than surfacing as a 500.
|
|
return await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
|
}
|
|
|
|
private static BlockItem BuildItem(Block block, int index, ReplaceBlockItem item)
|
|
{
|
|
var result = new BlockItem
|
|
{
|
|
BlockId = block.Id,
|
|
Index = index,
|
|
CollectionType = item.CollectionType,
|
|
CollectionId = item.CollectionId,
|
|
MultiCollectionId = item.MultiCollectionId,
|
|
SmartCollectionId = item.SmartCollectionId,
|
|
MediaItemId = item.MediaItemId,
|
|
SearchTitle = item.SearchTitle,
|
|
SearchQuery = item.SearchQuery,
|
|
PlaybackOrder = item.PlaybackOrder,
|
|
IncludeInProgramGuide = item.IncludeInProgramGuide,
|
|
DisableWatermarks = item.DisableWatermarks,
|
|
BlockItemWatermarks = [],
|
|
BlockItemGraphicsElements = []
|
|
};
|
|
|
|
foreach (int watermarkId in item.WatermarkIds)
|
|
{
|
|
result.BlockItemWatermarks ??= [];
|
|
result.BlockItemWatermarks.Add(
|
|
new BlockItemWatermark
|
|
{
|
|
BlockItem = result,
|
|
WatermarkId = watermarkId
|
|
});
|
|
}
|
|
|
|
foreach (int graphicsElementId in item.GraphicsElementIds)
|
|
{
|
|
result.BlockItemGraphicsElements ??= [];
|
|
result.BlockItemGraphicsElements.Add(
|
|
new BlockItemGraphicsElement
|
|
{
|
|
BlockItem = result,
|
|
GraphicsElementId = graphicsElementId
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static Task<Validation<BaseError, Block>> Validate(
|
|
TvContext dbContext,
|
|
ReplaceBlockItems request,
|
|
CancellationToken cancellationToken) =>
|
|
BlockMustExist(dbContext, request.BlockId, cancellationToken)
|
|
.BindT(block => MinutesMustBeValid(request, block))
|
|
.BindT(block => BlockNameMustBeValid(dbContext, block, request))
|
|
.BindT(block => CollectionTypesMustBeValid(request, block));
|
|
|
|
private static Task<Validation<BaseError, Block>> BlockMustExist(
|
|
TvContext dbContext,
|
|
int blockId,
|
|
CancellationToken cancellationToken) =>
|
|
dbContext.Blocks
|
|
.Include(b => b.Items)
|
|
.ThenInclude(i => i.BlockItemWatermarks)
|
|
.ThenInclude(wm => wm.Watermark)
|
|
.Include(b => b.Items)
|
|
.ThenInclude(i => i.BlockItemGraphicsElements)
|
|
.ThenInclude(ge => ge.GraphicsElement)
|
|
.SelectOneAsync(b => b.Id, b => b.Id == blockId, cancellationToken)
|
|
.Map(o => o.ToValidation<BaseError>("[BlockId] does not exist."));
|
|
|
|
private static Validation<BaseError, Block> MinutesMustBeValid(ReplaceBlockItems request, Block block) =>
|
|
Optional(block)
|
|
.Filter(_ => request.Minutes > 0 && request.Minutes % 5 == 0 && request.Minutes <= 24 * 60)
|
|
.ToValidation<BaseError>("Block duration must be between 5 minutes and 24 hours");
|
|
|
|
private static Validation<BaseError, Block> CollectionTypesMustBeValid(ReplaceBlockItems request, Block block) =>
|
|
request.Items.Map(item => CollectionTypeMustBeValid(item, block)).Sequence().Map(_ => block);
|
|
|
|
private static Validation<BaseError, Block> CollectionTypeMustBeValid(ReplaceBlockItem item, Block block)
|
|
{
|
|
switch (item.CollectionType)
|
|
{
|
|
case CollectionType.Collection:
|
|
if (item.CollectionId is null)
|
|
{
|
|
return BaseError.New("[Collection] is required for collection type 'Collection'");
|
|
}
|
|
|
|
break;
|
|
case CollectionType.TelevisionShow:
|
|
if (item.MediaItemId is null)
|
|
{
|
|
return BaseError.New("[MediaItem] is required for collection type 'TelevisionShow'");
|
|
}
|
|
|
|
break;
|
|
case CollectionType.TelevisionSeason:
|
|
if (item.MediaItemId is null)
|
|
{
|
|
return BaseError.New("[MediaItem] is required for collection type 'TelevisionSeason'");
|
|
}
|
|
|
|
break;
|
|
case CollectionType.Artist:
|
|
if (item.MediaItemId is null)
|
|
{
|
|
return BaseError.New("[MediaItem] is required for collection type 'Artist'");
|
|
}
|
|
|
|
break;
|
|
case CollectionType.MultiCollection:
|
|
if (item.MultiCollectionId is null)
|
|
{
|
|
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
|
|
}
|
|
|
|
break;
|
|
case CollectionType.SmartCollection:
|
|
if (item.SmartCollectionId is null)
|
|
{
|
|
return BaseError.New("[SmartCollection] is required for collection type 'SmartCollection'");
|
|
}
|
|
|
|
break;
|
|
case CollectionType.SearchQuery:
|
|
if (string.IsNullOrWhiteSpace(item.SearchQuery))
|
|
{
|
|
return BaseError.New("[SearchQuery] is required for collection type 'SearchQuery'");
|
|
}
|
|
|
|
break;
|
|
case CollectionType.FakeCollection:
|
|
default:
|
|
return BaseError.New("[CollectionType] is invalid");
|
|
}
|
|
|
|
return block;
|
|
}
|
|
|
|
private static async Task<Validation<BaseError, Block>> BlockNameMustBeValid(
|
|
TvContext dbContext,
|
|
Block block,
|
|
ReplaceBlockItems request)
|
|
{
|
|
if (request.Name.Length > 50)
|
|
{
|
|
return BaseError.New($"Block name \"{request.Name}\" is invalid");
|
|
}
|
|
|
|
Option<Block> maybeExisting = await dbContext.Blocks
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(d =>
|
|
d.Id != request.BlockId && d.BlockGroupId == request.BlockGroupId && d.Name == request.Name)
|
|
.Map(Optional);
|
|
|
|
return maybeExisting.IsSome
|
|
? BaseError.New($"A block named \"{request.Name}\" already exists in that block group")
|
|
: Success<BaseError, Block>(block);
|
|
}
|
|
}
|