Fans the frozen ETag/If-Match/412 recipe (Block reference, #253) onto the Playlist aggregate: - ReplacePlaylistItems command carries ExpectedVersion; the handler runs CheckVersion as a standalone Either after validation (so a stale write survives as 412, not flattened to 422 by Apply/Join), bumps Version unconditionally before saving, and persists via SaveChangesWithConcurrencyGuard (EF concurrency-token backstop). - PlaylistViewModel carries Version; the items GET sets a strong ETag and the PUT parses If-Match, threads it into the command, and returns the refreshed ETag on success (400 on a malformed If-Match). - Sibling item-adding handlers (AddItemsToPlaylist, AddMovie/Episode/ Season/ShowToPlaylist) bump Version too, since they mutate the same editor-visible item list. - SPA: playlists.ts exposes getPlaylistItemsWithMeta and an If-Match-aware updatePlaylist; PlaylistEditor holds the ETag in a ref, round-trips it on save, and opens a "changed elsewhere" ConfirmDialog on 412 (mirrors BlockEditor). Tests: new ReplacePlaylistItemsHandlerConcurrencyTests (stale/match/ force-write/no-op-bump/racing-save), new PlaylistController tests (ETag on GET items, 400/412/thread-version/force-write on PUT), and a vitest 412-conflict-dialog test for PlaylistsScreen. dotnet test: 1304/1304 green. web: npm run typecheck clean, npm run build clean, vitest 664/664 green. Ref #253 PR2.
160 lines
6.7 KiB
C#
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.ExpectedVersion));
|
|
|
|
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;
|
|
}
|
|
}
|