Files
ersatztv/ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItemsHandler.cs
T
timothyandClaude Opus 4.8 8090e10408 fix(api): #265 — If-Match evaluates per RFC 7232 (valid-but-non-matching → 412, not 400)
The shared optimistic-concurrency parser (ConcurrencyHeaders.ParseIfMatch) classified any
non-canonical/weak/list If-Match value as Malformed → 400. Per RFC 7232 §3.1 a syntactically
-valid entity-tag that simply doesn't strong-match must be 412; 400 is only for a genuine
grammar violation.

- Rewrite ParseIfMatch as a real RFC 7232 entity-tag/list parser: walks the comma-separated
  1#entity-tag list, validates each [W/]DQUOTE *etagc DQUOTE member, and collects the strong
  members whose opaque text is our canonical decimal. Weak / empty / non-canonical /
  out-of-range tags are valid but contribute no version (→ empty set → 412); genuine grammar
  violations (unquoted, SP-in-tag, unterminated, garbage) → 400.
- Reshape IfMatchCondition.ExpectedVersion : Option<int> → ExpectedVersions : Option<Seq<int>>
  and VersionedAggregateExtensions.CheckVersion → set membership (any strong match proceeds;
  empty set always 412). Threads through 10 replace/update commands + handlers + request
  mappers + 9 controllers.
- No wire-contract change (400 + 412 already declared on every PUT; the field is header-derived
  and internal — no DTO/route/response-type/OpenAPI change).
- Tests: ConcurrencyHeadersTests rewritten for the new classification (lists, weak, empty,
  non-canonical → Version/empty-set; grammar violations → Malformed) + new
  VersionedAggregateExtensionsTests for CheckVersion membership/empty-set/force-write.
- Docs: api-conventions.md §7a rewritten; decisions.md entry appended.

Refs #253 #197
fixes #265

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:09:36 +02:00

160 lines
6.7 KiB
C#

using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ReplacePlaylistItems, Either<BaseError, List<PlaylistItemViewModel>>>
{
public async Task<Either<BaseError, List<PlaylistItemViewModel>>> Handle(
ReplacePlaylistItems request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playlist> 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, Playlist> validated = LanguageExtensions.ToEither(validation)
.Bind(playlist => playlist.CheckVersion(request.ExpectedVersions));
return await validated.Match(
Right: playlist => Persist(dbContext, request, playlist, cancellationToken),
Left: error => Task.FromResult<Either<BaseError, List<PlaylistItemViewModel>>>(error));
}
private static async Task<Either<BaseError, List<PlaylistItemViewModel>>> Persist(
TvContext dbContext,
ReplacePlaylistItems request,
Playlist playlist,
CancellationToken cancellationToken)
{
playlist.Name = request.Name;
//playlist.DateUpdated = DateTime.UtcNow;
dbContext.RemoveRange(playlist.Items);
playlist.Items = request.Items.Map(i => BuildItem(playlist, 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).
playlist.Version++;
// 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.
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
return saved.Map(_ => playlist.Items.Map(Mapper.ProjectToViewModel).ToList());
}
private static PlaylistItem BuildItem(Playlist playlist, int index, ReplacePlaylistItem item) =>
new()
{
PlaylistId = playlist.Id,
Index = index,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.Count,
PlayAll = item.PlayAll,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
private static Task<Validation<BaseError, Playlist>> Validate(
TvContext dbContext,
ReplacePlaylistItems request,
CancellationToken cancellationToken) =>
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist));
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
TvContext dbContext,
int playlistId,
CancellationToken cancellationToken) =>
dbContext.Playlists
.Include(b => b.Items)
.SelectOneAsync(b => b.Id, b => b.Id == playlistId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("[PlaylistId] does not exist."));
private static Validation<BaseError, Playlist> CollectionTypesMustBeValid(
ReplacePlaylistItems request,
Playlist playlist) =>
request.Items.Map(item => CollectionTypeMustBeValid(item, playlist)).Sequence().Map(_ => playlist);
private static Validation<BaseError, Playlist> CollectionTypeMustBeValid(
ReplacePlaylistItem item,
Playlist playlist)
{
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.Movie:
case CollectionType.Episode:
case CollectionType.MusicVideo:
case CollectionType.OtherVideo:
case CollectionType.Song:
case CollectionType.Image:
if (item.MediaItemId is null)
{
return BaseError.New($"[MediaItem] is required for type '{item.CollectionType}'");
}
break;
case CollectionType.FakeCollection:
default:
return BaseError.New("[CollectionType] is invalid");
}
return playlist;
}
}