using System.IO.Abstractions; using System.Threading.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; using Channel = ErsatzTV.Core.Domain.Channel; namespace ErsatzTV.Application.Channels; public class DeleteChannelHandler : IRequestHandler> { private readonly IDbContextFactory _dbContextFactory; private readonly IFileSystem _fileSystem; private readonly ISearchTargets _searchTargets; private readonly ChannelWriter _workerChannel; public DeleteChannelHandler( ChannelWriter workerChannel, IDbContextFactory dbContextFactory, IFileSystem fileSystem, ISearchTargets searchTargets) { _workerChannel = workerChannel; _dbContextFactory = dbContextFactory; _fileSystem = fileSystem; _searchTargets = searchTargets; } public async Task> Handle(DeleteChannel request, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybeChannel = await dbContext.Channels .SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken); return await maybeChannel.Match( Some: async channel => { await DoDeletion(dbContext, channel, cancellationToken); return Right(Unit.Default); }, None: () => Task.FromResult( Left(new NotFoundError($"Channel {request.ChannelId} does not exist.")))); } private async Task DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken) { // Delete the guide cache file through the filesystem abstraction (so it's observable under a // MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the // process crashes in between (nothing reaps it, and GetChannelGuideHandler serves everything // in the cache folder). The guide xml is regenerable on demand, so losing it pre-commit is safe (#254). string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml"); if (_fileSystem.File.Exists(cacheFile)) { _fileSystem.File.Delete(cacheFile); } int channelId = channel.Id; dbContext.Channels.Remove(channel); await dbContext.SaveChangesAsync(cancellationToken); // Clean up the system-owned weighted-auto-tune artifacts this channel created (#425): the // MultiCollection (its cascade removes the now-dangling flood schedule item) and its per-source // SmartCollections (cascade removes their join rows). Null OwnedByChannelId = a user collection, left // untouched. Non-weighted (#69 single-SmartCollection) auto-tune channels set no ownership, so their // pre-existing orphan-on-delete behavior is unchanged. await dbContext.MultiCollections .Where(mc => mc.OwnedByChannelId == channelId) .ExecuteDeleteAsync(cancellationToken); await dbContext.SmartCollections .Where(sc => sc.OwnedByChannelId == channelId) .ExecuteDeleteAsync(cancellationToken); _searchTargets.SearchTargetsChanged(); // refresh channel list to remove channel that has no playout — post-commit side effect runs on // CancellationToken.None so a late request cancellation can't abort it after the delete committed (#254) await _workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None); return Unit.Default; } }