using System.Globalization; using System.Text.RegularExpressions; using System.Threading.Channels; using ErsatzTV.Application.Subtitles; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; using static ErsatzTV.Application.Channels.ChannelValidations; using static ErsatzTV.Application.Channels.Mapper; using Channel = ErsatzTV.Core.Domain.Channel; namespace ErsatzTV.Application.Channels; public class UpdateChannelHandler( ChannelWriter workerChannel, IDbContextFactory dbContextFactory, ISearchTargets searchTargets, IRemoteLogoCacher remoteLogoCacher) : IRequestHandler> { public async Task> Handle( UpdateChannel request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybeChannel = await dbContext.Channels .Include(c => c.Artwork) .Include(c => c.Watermark) .Include(c => c.Playouts) .Include(c => c.ChannelGraphicsElements) .SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken); return await maybeChannel.Match( Some: async channel => { Validation validation = await Validate(dbContext, request, channel, cancellationToken); return await validation.Match( Succ: async c => { Either resolvedLogo = await ResolveLogoPath(request, cancellationToken); return await resolvedLogo.Match( Right: async logoPath => Right( await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)), Left: e => Task.FromResult(Left(e))); }, Fail: errors => Task.FromResult(Left(errors.Join()))); }, None: () => Task.FromResult( Left( new NotFoundError($"Channel {request.ChannelId} does not exist.")))); } // Resolve the incoming logo path into a value safe to persist. An external http(s) URL is // downloaded and cached (a cacher Left fails the whole save); an empty path (logo removal) or an // already-local/cached path passes through unchanged. (ersatztv#525) private async Task> ResolveLogoPath( UpdateChannel request, CancellationToken cancellationToken) { string path = request.Logo?.Path ?? string.Empty; if (!Artwork.IsExternalUrl(path)) { return path; } Either cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken); return cached; } private async Task ApplyUpdateRequest( TvContext dbContext, Channel c, UpdateChannel update, string resolvedLogoPath, CancellationToken cancellationToken) { bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg; c.Name = update.Name; c.Number = update.Number; c.SortNumber = double.Parse(update.Number, CultureInfo.InvariantCulture); c.Group = update.Group; c.Categories = update.Categories; c.FFmpegProfileId = update.FFmpegProfileId; c.SlugSeconds = update.SlugSeconds; c.StreamSelectorMode = update.StreamSelectorMode; c.StreamSelector = update.StreamSelector; c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode; c.PreferredAudioTitle = update.PreferredAudioTitle; c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode; c.SubtitleMode = update.SubtitleMode; c.MusicVideoCreditsMode = update.MusicVideoCreditsMode; c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate; c.SongVideoMode = update.SongVideoMode; c.TranscodeMode = update.TranscodeMode; c.IdleBehavior = update.IdleBehavior; c.IsEnabled = update.IsEnabled; c.ShowInEpg = update.IsEnabled && update.ShowInEpg; c.Artwork ??= []; if (!string.IsNullOrWhiteSpace(resolvedLogoPath)) { string logo = resolvedLogoPath; if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal)) { logo = logo.Replace("iptv/logos/", string.Empty); } Option maybeLogo = c.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo).HeadOrNone(); foreach (Artwork artwork in maybeLogo) { artwork.Path = logo; artwork.OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType) ? update.Logo.ContentType : null; artwork.DateUpdated = DateTime.UtcNow; } if (maybeLogo.IsNone) { var artwork = new Artwork { Path = logo, OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType) ? update.Logo.ContentType : null, DateAdded = DateTime.UtcNow, DateUpdated = DateTime.UtcNow, ArtworkKind = ArtworkKind.Logo }; c.Artwork.Add(artwork); } } else { await dbContext.Entry(c) .Collection(channel => channel.Artwork) .LoadAsync(cancellationToken); foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList()) { c.Artwork.Remove(artwork); dbContext.Artwork.Remove(artwork); } } c.PlayoutSource = update.PlayoutSource; c.PlayoutMode = update.PlayoutMode; if (c.PlayoutSource is ChannelPlayoutSource.Mirror) { c.PlayoutMode = ChannelPlayoutMode.Continuous; hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId; hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset; c.MirrorSourceChannelId = update.MirrorSourceChannelId; c.PlayoutOffset = update.PlayoutOffset; } else { c.MirrorSourceChannelId = null; c.PlayoutOffset = null; } c.StreamingMode = update.StreamingMode; c.WatermarkId = update.WatermarkId; c.FallbackFillerId = update.FallbackFillerId; c.ChannelGraphicsElements ??= []; var desired = update.GraphicsElementIds?.Distinct().ToList() ?? []; c.ChannelGraphicsElements.RemoveAll(cge => !desired.Contains(cge.GraphicsElementId)); foreach (int id in desired.Where(id => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != id))) { c.ChannelGraphicsElements.Add(new ChannelGraphicsElement { ChannelId = c.Id, GraphicsElementId = id }); } await dbContext.SaveChangesAsync(cancellationToken); searchTargets.SearchTargetsChanged(); // post-commit side effect runs on CancellationToken.None so a late request cancellation // can't abort it after the commit landed (#254) if (c.SubtitleMode != ChannelSubtitleMode.None) { Option maybePlayout = await dbContext.Playouts .SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, CancellationToken.None); foreach (Playout playout in maybePlayout) { await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), CancellationToken.None); } } await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None); if (hasEpgChange) { await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None); } // Deliberately NOT Mapper.GetPlayoutsCount: this handler's query (see Handle) doesn't include // MirrorSourceChannel, so the shared helper would read that navigation as null and return the // same own-playouts-only count anyway — with a false air of Mirror-awareness. Harmless today // because ChannelController discards this view model and re-projects through // GetChannelByIdForApi, so this count never reaches the wire. If you ever return it directly, // fix the QUERY first (add the MirrorSourceChannel ThenInclude) — swapping in the helper alone // would report 0 playouts for a working mirror channel. return ProjectToViewModel(c, c.Playouts?.Count ?? 0); } private static async Task> Validate( TvContext dbContext, UpdateChannel request, Channel channel, CancellationToken cancellationToken) { Validation channelValidation = (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken), await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken), ValidateShowInEpg(request.IsEnabled, request.ShowInEpg), ValidateLogo(request.Logo?.Path)) .Apply((_, _, _, _, _) => channel); // combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark / // fallback filler) with the channel validation; splitting keeps tuple arity within // LanguageExt's supported applicative range while still accumulating all errors return (ValidateGroup(request.Group), await FFmpegProfileMustExist(dbContext, request, cancellationToken), await WatermarkMustExist(dbContext, request, cancellationToken), await FillerPresetMustExist(dbContext, request, cancellationToken), channelValidation) .Apply((_, _, _, _, c) => c); } private static async Task> FFmpegProfileMustExist( TvContext dbContext, UpdateChannel request, CancellationToken cancellationToken) { bool exists = await dbContext.FFmpegProfiles .AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken); if (exists) { return request.FFmpegProfileId; } return BaseError.New($"FFmpegProfile {request.FFmpegProfileId} does not exist."); } private static async Task>> WatermarkMustExist( TvContext dbContext, UpdateChannel request, CancellationToken cancellationToken) { if (request.WatermarkId is null) { return Option.None; } bool exists = await dbContext.ChannelWatermarks .AnyAsync(w => w.Id == request.WatermarkId, cancellationToken); if (exists) { return Optional(request.WatermarkId); } return BaseError.New($"Watermark {request.WatermarkId} does not exist."); } private static async Task>> FillerPresetMustExist( TvContext dbContext, UpdateChannel request, CancellationToken cancellationToken) { if (request.FallbackFillerId is null) { return Option.None; } bool exists = await dbContext.FillerPresets .Filter(fp => fp.FillerKind == FillerKind.Fallback) .AnyAsync(w => w.Id == request.FallbackFillerId, cancellationToken); if (exists) { return Optional(request.FallbackFillerId); } return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist."); } private static async Task> MirrorSourceMustBeValid( TvContext dbContext, UpdateChannel request, Channel channel, CancellationToken cancellationToken) { if (request.PlayoutSource is not ChannelPlayoutSource.Mirror) { return Unit.Default; } // a channel with its own playout already built (Generated mode) cannot become a Mirror — // Mirror channels relay another channel's playout and never build one of their own, so // switching this transition on would strand the existing playout. This used to be // silently coerced back to Generated (issue #401); reject the transition instead so the // caller sees why the requested Mirror source was not applied. A round-trip that keeps // PlayoutSource as Generated never reaches this check. if (channel.Playouts.Count > 0) { return BaseError.New( "Channel cannot switch to Mirror playout source while it has a playout; reset or delete the existing playout first."); } Option maybeMirrorSource = await dbContext.Channels .AsNoTracking() .SelectOneAsync( c => c.Id == request.MirrorSourceChannelId, c => c.Id == request.MirrorSourceChannelId, cancellationToken); if (maybeMirrorSource.IsNone) { return BaseError.New("Mirror source channel does not exist."); } foreach (var mirrorSource in maybeMirrorSource) { if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated) { return BaseError.New( $"Mirror source channel {mirrorSource.Name} must use generated playout source"); } } foreach (TimeSpan playoutOffset in Optional(request.PlayoutOffset)) { if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12)) { return BaseError.New("Playout offset must not be greater than 12 hours"); } } return Unit.Default; } private static Validation ValidateName(UpdateChannel updateChannel) => updateChannel.NotEmpty(c => c.Name) .Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name)); private static async Task> ValidateNumber( TvContext dbContext, UpdateChannel updateChannel, CancellationToken cancellationToken) { int matchId = await dbContext.Channels .SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number, cancellationToken) .Match(c => c.Id, () => updateChannel.ChannelId); if (matchId == updateChannel.ChannelId) { if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator)) { return updateChannel.Number; } return BaseError.New("Invalid channel number; two decimals are allowed for subchannels"); } return BaseError.New("Channel number must be unique"); } }