using System.Threading.Channels; using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Search; 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 AddShowToCollectionHandler : IRequestHandler> { private readonly ChannelWriter _channel; private readonly IDbContextFactory _dbContextFactory; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ChannelWriter _searchChannel; public AddShowToCollectionHandler( IDbContextFactory dbContextFactory, IMediaCollectionRepository mediaCollectionRepository, ChannelWriter channel, ChannelWriter searchChannel) { _dbContextFactory = dbContextFactory; _mediaCollectionRepository = mediaCollectionRepository; _channel = channel; _searchChannel = searchChannel; } public async Task> Handle( AddShowToCollection request, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Validation validation = await Validate(dbContext, request, cancellationToken); return await validation.Apply(parameters => ApplyAddShowRequest(dbContext, parameters)); } private async Task ApplyAddShowRequest(TvContext dbContext, Parameters parameters) { // No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is // already a member (also avoids a duplicate CollectionItem row) โ€” #269. if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Show.Id)) { return Unit.Default; } parameters.Collection.MediaItems.Add(parameters.Show); // 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). Post-commit enqueues run on CancellationToken.None. parameters.Collection.Version++; if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None)) { // A concurrent add of this same item won the composite-PK race and already inserted the row, // rotated the collection ETag, and fanned out the rebuild โ€” so this is now an idempotent // no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308 return Unit.Default; } await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]), CancellationToken.None); // refresh all playouts that use this collection foreach (int playoutId in await _mediaCollectionRepository .PlayoutIdsUsingCollection(parameters.Collection.Id)) { await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); } return Unit.Default; } private static async Task> Validate( TvContext dbContext, AddShowToCollection request, CancellationToken cancellationToken) => (await CollectionMustExist(dbContext, request, cancellationToken), await ValidateShow(dbContext, request, cancellationToken)) .Apply((collection, episode) => new Parameters(collection, episode)); private static Task> CollectionMustExist( TvContext dbContext, AddShowToCollection request, CancellationToken cancellationToken) => dbContext.Collections .Include(c => c.MediaItems) .SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken) .Map(o => o.ToValidation("Collection does not exist.")); private static Task> ValidateShow( TvContext dbContext, AddShowToCollection request, CancellationToken cancellationToken) => dbContext.Shows .SelectOneAsync(m => m.Id, e => e.Id == request.ShowId, cancellationToken) .Map(o => o.ToValidation("Show does not exist")); private sealed record Parameters(Collection Collection, Show Show); }