using System.Threading.Channels; using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.MediaCollections; public class UpdateRerunCollectionHandler( IDbContextFactory dbContextFactory, IMediaCollectionRepository mediaCollectionRepository, ChannelWriter channel) : IRequestHandler> { public async Task> Handle( UpdateRerunCollection 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.ExpectedVersions)); return await validated.Match( Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken), Left: error => Task.FromResult>(error)); } private async Task> ApplyUpdateRequest( TvContext dbContext, RerunCollection c, UpdateRerunCollection request, CancellationToken cancellationToken) { c.Name = request.Name; c.CollectionType = request.CollectionType; c.CollectionId = request.Collection?.Id; c.MultiCollectionId = request.MultiCollection?.Id; c.SmartCollectionId = request.SmartCollection?.Id; c.MediaItemId = request.MediaItem?.MediaItemId; c.FirstRunPlaybackOrder = request.FirstRunPlaybackOrder; c.RerunPlaybackOrder = request.RerunPlaybackOrder; // Unconditional bump (issue #253 §7a / M1) then guarded save, which maps a racing // DbUpdateConcurrencyException to PreconditionFailedError (→ 412). c.Version++; Either saveResult = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken); if (saveResult.IsLeft) { return saveResult; } // Refresh all playouts that use this rerun collection. The old `SaveChangesAsync() > 0` gate is // always true once the version bumps unconditionally (M2), so run the refresh on any successful // save. Post-commit enqueue on CancellationToken.None so a late cancellation can't drop the // rebuild after the commit landed (#254). foreach (int playoutId in await mediaCollectionRepository.PlayoutIdsUsingRerunCollection( request.RerunCollectionId)) { await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); } return Unit.Default; } private static async Task> Validate( TvContext dbContext, UpdateRerunCollection request, CancellationToken cancellationToken) => (await RerunCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request), await RerunCollectionSelectionValidation.SelectedEntityMustExist( dbContext, request.CollectionType, request.Collection?.Id, request.MultiCollection?.Id, request.SmartCollection?.Id, request.MediaItem?.MediaItemId, cancellationToken)) .Apply((collectionToUpdate, _, _) => collectionToUpdate); private static Task> RerunCollectionMustExist( TvContext dbContext, UpdateRerunCollection updateCollection, CancellationToken cancellationToken) => dbContext.RerunCollections .SelectOneAsync(c => c.Id, c => c.Id == updateCollection.RerunCollectionId, cancellationToken) .Map(o => o.ToValidation("Rerun collection does not exist.")); private static async Task> ValidateName( TvContext dbContext, UpdateRerunCollection updateCollection) { Validation result1 = updateCollection.NotEmpty(c => c.Name) .Bind(_ => updateCollection.NotLongerThan(50)(c => c.Name)); bool duplicateName = await dbContext.RerunCollections .AnyAsync(c => c.Id != updateCollection.RerunCollectionId && c.Name == updateCollection.Name); Validation result2 = duplicateName ? Fail("Rerun collection name must be unique") : Success(Unit.Default); return (result1, result2).Apply((_, _) => updateCollection.Name); } }