using System.Threading.Channels; using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.MediaCollections; public class UpdateMultiCollectionHandler : IRequestHandler> { private readonly ChannelWriter _channel; private readonly IDbContextFactory _dbContextFactory; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ISearchTargets _searchTargets; public UpdateMultiCollectionHandler( IDbContextFactory dbContextFactory, IMediaCollectionRepository mediaCollectionRepository, ChannelWriter channel, ISearchTargets searchTargets) { _dbContextFactory = dbContextFactory; _mediaCollectionRepository = mediaCollectionRepository; _channel = channel; _searchTargets = searchTargets; } public async Task> Handle( UpdateMultiCollection request, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Validation validation = await Validate(dbContext, request, cancellationToken); // Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which // Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a. Either validated = LanguageExtensions.ToEither(validation) .Bind(c => c.CheckVersion(request.ExpectedVersion)); return await validated.Match( Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken), Left: error => Task.FromResult>(error)); } private async Task> ApplyUpdateRequest( TvContext dbContext, MultiCollection c, UpdateMultiCollection request, CancellationToken cancellationToken) { c.Name = request.Name; // Bump the version on this first save (issue #253 §7a): it rotates other clients' ETags and, // via IsConcurrencyToken, closes the load→save race — a stale writer's WHERE Version=@orig hits // 0 rows → PreconditionFailedError (412). Saving the name first also keeps a name-only change // from triggering a playout rebuild (the item save below stays gated on real item changes). c.Version++; Either nameSave = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken); if (nameSave.IsLeft) { return nameSave; } var toAdd = request.Items .Filter(i => i.CollectionId.HasValue) // ReSharper disable once PossibleInvalidOperationException .Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId.Value)) .Map(i => new MultiCollectionItem { // ReSharper disable once PossibleInvalidOperationException CollectionId = i.CollectionId.Value, MultiCollectionId = c.Id, ScheduleAsGroup = i.ScheduleAsGroup, PlaybackOrder = i.PlaybackOrder }) .ToList(); var toRemove = c.MultiCollectionItems .Filter(i => request.Items.All(i2 => i2.CollectionId != i.CollectionId)) .ToList(); // remove items that are no longer present c.MultiCollectionItems.RemoveAll(toRemove.Contains); // update existing items foreach (MultiCollectionItem item in c.MultiCollectionItems) { foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(i => i.CollectionId == item.CollectionId)) { item.ScheduleAsGroup = incoming.ScheduleAsGroup; item.PlaybackOrder = incoming.PlaybackOrder; } } // add new items c.MultiCollectionItems.AddRange(toAdd); var toAddSmart = request.Items .Filter(i => i.SmartCollectionId.HasValue) // ReSharper disable once PossibleInvalidOperationException .Filter(i => c.MultiCollectionSmartItems.All(i2 => i2.SmartCollectionId != i.SmartCollectionId.Value)) .Map(i => new MultiCollectionSmartItem { // ReSharper disable once PossibleInvalidOperationException SmartCollectionId = i.SmartCollectionId.Value, MultiCollectionId = c.Id, ScheduleAsGroup = i.ScheduleAsGroup, PlaybackOrder = i.PlaybackOrder }) .ToList(); var toRemoveSmart = c.MultiCollectionSmartItems .Filter(i => request.Items.All(i2 => i2.SmartCollectionId != i.SmartCollectionId)) .ToList(); // remove items that are no longer present c.MultiCollectionSmartItems.RemoveAll(toRemoveSmart.Contains); // update existing items foreach (MultiCollectionSmartItem item in c.MultiCollectionSmartItems) { foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(i => i.SmartCollectionId == item.SmartCollectionId)) { item.ScheduleAsGroup = incoming.ScheduleAsGroup; item.PlaybackOrder = incoming.PlaybackOrder; } } // add new items c.MultiCollectionSmartItems.AddRange(toAddSmart); // rebuild playouts if (await dbContext.SaveChangesAsync(cancellationToken) > 0) { _searchTargets.SearchTargetsChanged(); // refresh all playouts that use this collection // post-commit side effect runs on CancellationToken.None so a late request cancellation // can't abort it after the commit landed (#254) foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection( request.MultiCollectionId)) { await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); } } return Unit.Default; } private static async Task> Validate( TvContext dbContext, UpdateMultiCollection request, CancellationToken cancellationToken) => (await MultiCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request)) .Apply((collectionToUpdate, _) => collectionToUpdate); private static Task> MultiCollectionMustExist( TvContext dbContext, UpdateMultiCollection updateCollection, CancellationToken cancellationToken) => dbContext.MultiCollections .Include(mc => mc.MultiCollectionItems) .Include(mc => mc.MultiCollectionSmartItems) .SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId, cancellationToken) .Map(o => o.ToValidation("MultiCollection does not exist.")); private static async Task> ValidateName( TvContext dbContext, UpdateMultiCollection updateMultiCollection) { Validation result1 = updateMultiCollection.NotEmpty(c => c.Name) .Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name)); bool duplicateName = await dbContext.MultiCollections .AnyAsync(c => c.Id != updateMultiCollection.MultiCollectionId && c.Name == updateMultiCollection.Name); Validation result2 = duplicateName ? Fail("MultiCollection name must be unique") : Success(Unit.Default); return (result1, result2).Apply((_, _) => updateMultiCollection.Name); } }