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 AddItemsToCollectionHandler : IRequestHandler> { private readonly ChannelWriter _channel; private readonly IDbContextFactory _dbContextFactory; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMovieRepository _movieRepository; private readonly ChannelWriter _searchChannel; private readonly ITelevisionRepository _televisionRepository; public AddItemsToCollectionHandler( IDbContextFactory dbContextFactory, IMediaCollectionRepository mediaCollectionRepository, IMovieRepository movieRepository, ITelevisionRepository televisionRepository, ChannelWriter channel, ChannelWriter searchChannel) { _dbContextFactory = dbContextFactory; _mediaCollectionRepository = mediaCollectionRepository; _movieRepository = movieRepository; _televisionRepository = televisionRepository; _channel = channel; _searchChannel = searchChannel; } // A duplicate-key race can roll back the whole batch (#308); recompute membership from a fresh // context and retry with only the still-missing items. Bounded to avoid a livelock — the common // no-collision path runs the loop body exactly once. private const int MaxDuplicateRetries = 5; public async Task> Handle( AddItemsToCollection request, CancellationToken cancellationToken) { for (var attempt = 0; ; attempt++) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken); // true = terminal (nothing to add, or our batch committed); false = a duplicate-key race // rolled the batch back, recompute membership and retry. Either attemptResult = await maybeCollection.Match( Some: async collection => { Validation validation = await Validate(dbContext, request, collection, cancellationToken); return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken)); }, None: () => Task.FromResult>( new NotFoundError($"Collection {request.CollectionId} does not exist."))); if (attemptResult.IsLeft) { return attemptResult.Map(_ => Unit.Default); } bool committed = attemptResult.Match(Left: _ => false, Right: done => done); if (committed) { return Unit.Default; } // A concurrent add inserted one+ of our items first; recompute against fresh membership. if (attempt >= MaxDuplicateRetries) { return BaseError.New( "Concurrent modification while adding items to the collection; please retry."); } } } private async Task ApplyAddItemsRequest( TvContext dbContext, Collection collection, AddItemsToCollection request, CancellationToken cancellationToken) { var allItems = request.MovieIds .Append(request.ShowIds) .Append(request.SeasonIds) .Append(request.EpisodeIds) .Append(request.ArtistIds) .Append(request.MusicVideoIds) .Append(request.OtherVideoIds) .Append(request.SongIds) .Append(request.ImageIds) .Append(request.RemoteStreamIds) .ToList(); var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList(); // No-op when every requested item is already a member: don't rotate the ETag or fan out // rebuilds for an idempotent re-add — #269. Terminal success (no retry). if (toAddIds.Count == 0) { return true; } List toAdd = await dbContext.MediaItems .Filter(mi => toAddIds.Contains(mi.Id)) .ToListAsync(cancellationToken); collection.MediaItems.AddRange(toAdd); // 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 add takes no If-Match, so a // benign race must not 500 (#253/#269 §7a). collection.Version++; // A concurrent add of an overlapping item won the composite-PK race and rolled back this whole // batch. Unlike the single-item handlers (idempotent no-op), a bulk add must NOT drop the items // that did NOT collide — signal the caller to recompute membership and retry the still-missing // ones. #308 if (!await dbContext.TrySaveChangesForcingVersion(cancellationToken)) { return false; } // 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(toAddIds.ToArray()), CancellationToken.None); // refresh all playouts that use this collection foreach (int playoutId in await _mediaCollectionRepository .PlayoutIdsUsingCollection(request.CollectionId)) { await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); } return true; } private async Task> Validate( TvContext dbContext, AddItemsToCollection request, Collection collection, CancellationToken cancellationToken) => (await ValidateMovies(request), await ValidateShows(request), await ValidateSeasons(request), await ValidateEpisodes(request), await ValidateMediaItems(dbContext, request, cancellationToken)) .Apply((_, _, _, _, _) => collection); private static Task> CollectionMustExist( TvContext dbContext, AddItemsToCollection request, CancellationToken cancellationToken) => dbContext.Collections .Include(c => c.MediaItems) .SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken) .Map(identity); private Task> ValidateMovies(AddItemsToCollection request) => _movieRepository.AllMoviesExist(request.MovieIds) .Map(Optional) .Filter(v => v == true) .MapT(_ => Unit.Default) .Map(v => v.ToValidation("Movie does not exist")); private Task> ValidateShows(AddItemsToCollection request) => _televisionRepository.AllShowsExist(request.ShowIds) .Map(Optional) .Filter(v => v == true) .MapT(_ => Unit.Default) .Map(v => v.ToValidation("Show does not exist")); private Task> ValidateSeasons(AddItemsToCollection request) => _televisionRepository.AllSeasonsExist(request.SeasonIds) .Map(Optional) .Filter(v => v == true) .MapT(_ => Unit.Default) .Map(v => v.ToValidation("Season does not exist")); private Task> ValidateEpisodes(AddItemsToCollection request) => _televisionRepository.AllEpisodesExist(request.EpisodeIds) .Map(Optional) .Filter(v => v == true) .MapT(_ => Unit.Default) .Map(v => v.ToValidation("Episode does not exist")); private static async Task> ValidateMediaItems( TvContext dbContext, AddItemsToCollection request, CancellationToken cancellationToken) { List ids = GetRequestedMediaItemIds(request).Distinct().ToList(); int existingCount = await dbContext.MediaItems .CountAsync(mi => ids.Contains(mi.Id), cancellationToken); return existingCount == ids.Count ? Unit.Default : BaseError.New("Media item does not exist"); } private static IEnumerable GetRequestedMediaItemIds(AddItemsToCollection request) => request.MovieIds .Append(request.ShowIds) .Append(request.SeasonIds) .Append(request.EpisodeIds) .Append(request.ArtistIds) .Append(request.MusicVideoIds) .Append(request.OtherVideoIds) .Append(request.SongIds) .Append(request.ImageIds) .Append(request.RemoteStreamIds); }