using System.Threading.Channels; using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Search; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; 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 RemoveItemsFromCollectionHandler : IRequestHandler> { private readonly ChannelWriter _channel; private readonly IDbContextFactory _dbContextFactory; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ChannelWriter _searchChannel; public RemoveItemsFromCollectionHandler( IDbContextFactory dbContextFactory, IMediaCollectionRepository mediaCollectionRepository, ChannelWriter channel, ChannelWriter searchChannel) { _dbContextFactory = dbContextFactory; _mediaCollectionRepository = mediaCollectionRepository; _channel = channel; _searchChannel = searchChannel; } public async Task> Handle( RemoveItemsFromCollection request, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken); return await maybeCollection.Match( Some: collection => ApplyRemoveItemsRequest(dbContext, request, collection, cancellationToken), None: () => Task.FromResult>( new NotFoundError($"Collection {request.MediaCollectionId} does not exist."))); } private async Task> ApplyRemoveItemsRequest( TvContext dbContext, RemoveItemsFromCollection request, Collection collection, CancellationToken cancellationToken) { List requestedIds = request.MediaItemIds.Distinct().ToList(); var itemsToRemove = collection.MediaItems .Filter(m => requestedIds.Contains(m.Id)) .ToList(); if (itemsToRemove.Count != requestedIds.Count) { return new NotFoundError("Collection item does not exist."); } // No-op when nothing is actually removed: don't rotate the ETag or fan out rebuilds — #269. if (itemsToRemove.Count == 0) { return Unit.Default; } itemsToRemove.ForEach(m => collection.MediaItems.Remove(m)); // Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269); // force-write past a concurrent replace-all Version bump — this remove takes no If-Match, so a // benign race must not 500 (#253/#269 §7a). collection.Version++; await dbContext.SaveChangesForcingVersion(cancellationToken); // post-commit side effect runs on CancellationToken.None so a late request cancellation // can't abort it after the commit landed (#254) await _searchChannel.WriteAsync( new ReindexMediaItems(itemsToRemove.Select(mi => mi.Id).ToArray()), CancellationToken.None); // refresh all playouts that use this collection foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id)) { await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); } return Unit.Default; } private static Task> CollectionMustExist( TvContext dbContext, RemoveItemsFromCollection request, CancellationToken cancellationToken) => dbContext.Collections .Include(c => c.MediaItems) .SelectOneAsync(c => c.Id, c => c.Id == request.MediaCollectionId, cancellationToken) .Map(identity); }