using System.Globalization; using System.Text.RegularExpressions; using System.Threading.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Streaming.Graphics; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; using static ErsatzTV.Application.Channels.ChannelValidations; using Channel = ErsatzTV.Core.Domain.Channel; namespace ErsatzTV.Application.Channels; public class CreateChannelHandler( ChannelWriter workerChannel, IDbContextFactory dbContextFactory, ISearchTargets searchTargets, IRemoteLogoCacher remoteLogoCacher) : IRequestHandler> { public async Task> Handle( CreateChannel request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Validation validation = await Validate(dbContext, request, cancellationToken); return await validation.Match( Succ: async channel => { Either resolvedLogo = await ResolveLogoPath(request, cancellationToken); return await resolvedLogo.Match( Right: async logoPath => { ApplyResolvedLogo(request, channel, logoPath); return Right( await PersistChannel(dbContext, channel, cancellationToken)); }, Left: e => Task.FromResult(Left(e))); }, Fail: errors => Task.FromResult(Left(errors.Join()))); } // 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 or an // already-local/cached path passes through unchanged. (ersatztv#525) private async Task> ResolveLogoPath( CreateChannel 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; } // When the incoming logo was an external URL, swap the downloaded cache name onto the logo // artwork built during validation so no URL is ever persisted in Artwork.Path. (ersatztv#525) private static void ApplyResolvedLogo(CreateChannel request, Channel channel, string resolvedLogoPath) { if (!Artwork.IsExternalUrl(request.Logo?.Path ?? string.Empty)) { return; } foreach (Artwork logo in channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo)) { logo.Path = resolvedLogoPath; } } private async Task PersistChannel( TvContext dbContext, Channel channel, CancellationToken cancellationToken) { await ChannelGraphicsDefaults.Attach(dbContext, channel, cancellationToken); await dbContext.Channels.AddAsync(channel); await dbContext.SaveChangesAsync(); searchTargets.SearchTargetsChanged(); await workerChannel.WriteAsync(new RefreshChannelList()); return new CreateChannelResult(channel.Id); } private static async Task> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) { Validation channelValidation = (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken), await FFmpegProfileMustExist(dbContext, request, cancellationToken), await WatermarkMustExist(dbContext, request, cancellationToken), await FillerPresetMustExist(dbContext, request, cancellationToken), await MirrorSourceMustBeValid(dbContext, request, cancellationToken), ValidateShowInEpg(request.IsEnabled, request.ShowInEpg), ValidateLogo(request.Logo?.Path)) .Apply(( name, number, ffmpegProfileId, watermarkId, fillerPresetId, _, _, _) => { var artwork = new List(); if (!string.IsNullOrWhiteSpace(request.Logo?.Path)) { string logo = request.Logo.Path; if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal)) { logo = logo.Replace("iptv/logos/", string.Empty); } artwork.Add( new Artwork { Path = logo, ArtworkKind = ArtworkKind.Logo, OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null, DateAdded = DateTime.UtcNow, DateUpdated = DateTime.UtcNow }); } var channel = new Channel(Guid.NewGuid()) { Name = name, Number = number, SortNumber = double.Parse(number, CultureInfo.InvariantCulture), Group = request.Group, Categories = request.Categories, FFmpegProfileId = ffmpegProfileId, SlugSeconds = request.SlugSeconds, PlayoutSource = request.PlayoutSource, PlayoutMode = request.PlayoutMode, MirrorSourceChannelId = request.MirrorSourceChannelId, PlayoutOffset = request.PlayoutOffset, StreamingMode = request.StreamingMode, Artwork = artwork, StreamSelectorMode = request.StreamSelectorMode, StreamSelector = request.StreamSelector, PreferredAudioLanguageCode = request.PreferredAudioLanguageCode, PreferredAudioTitle = request.PreferredAudioTitle, PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode, SubtitleMode = request.SubtitleMode, MusicVideoCreditsMode = request.MusicVideoCreditsMode, MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate, SongVideoMode = request.SongVideoMode, TranscodeMode = request.TranscodeMode, IdleBehavior = request.IdleBehavior, IsEnabled = request.IsEnabled, ShowInEpg = request.IsEnabled && request.ShowInEpg, Origin = ChannelOrigin.UserCreated }; if (channel.PlayoutSource is ChannelPlayoutSource.Mirror) { channel.PlayoutMode = ChannelPlayoutMode.Continuous; } else { channel.MirrorSourceChannelId = null; channel.PlayoutOffset = null; } foreach (int id in watermarkId) { channel.WatermarkId = id; } foreach (int id in fillerPresetId) { channel.FallbackFillerId = id; } return channel; }); // combine the page-only Group rule with the channel validation (keeps tuple arity within // LanguageExt's supported applicative range while still accumulating all errors) return (ValidateGroup(request.Group), channelValidation).Apply((_, channel) => channel); } private static Validation ValidateName(CreateChannel createChannel) => createChannel.NotEmpty(c => c.Name) .Bind(_ => createChannel.NotLongerThan(50)(c => c.Name)); private static async Task> ValidateNumber( TvContext dbContext, CreateChannel createChannel, CancellationToken cancellationToken) { Option maybeExistingChannel = await dbContext.Channels .SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number, cancellationToken); return maybeExistingChannel.Match>( _ => BaseError.New("Channel number must be unique"), () => { if (Regex.IsMatch(createChannel.Number, Channel.NumberValidator)) { return createChannel.Number; } return BaseError.New("Invalid channel number; two decimals are allowed for subchannels"); }); } private static async Task> FFmpegProfileMustExist( TvContext dbContext, CreateChannel createChannel, CancellationToken cancellationToken) { bool exists = await dbContext.FFmpegProfiles .AnyAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken); if (exists) { return createChannel.FFmpegProfileId; } return BaseError.New($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."); } private static async Task>> WatermarkMustExist( TvContext dbContext, CreateChannel createChannel, CancellationToken cancellationToken) { if (createChannel.WatermarkId is null) { return Option.None; } bool exists = await dbContext.ChannelWatermarks .AnyAsync(w => w.Id == createChannel.WatermarkId, cancellationToken); if (exists) { return Optional(createChannel.WatermarkId); } return BaseError.New($"Watermark {createChannel.WatermarkId} does not exist."); } private static async Task>> FillerPresetMustExist( TvContext dbContext, CreateChannel createChannel, CancellationToken cancellationToken) { if (createChannel.FallbackFillerId is null) { return Option.None; } bool exists = await dbContext.FillerPresets .Filter(fp => fp.FillerKind == FillerKind.Fallback) .AnyAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken); if (exists) { return Optional(createChannel.FallbackFillerId); } return BaseError.New($"Fallback filler {createChannel.FallbackFillerId} does not exist."); } private static async Task> MirrorSourceMustBeValid( TvContext dbContext, CreateChannel createChannel, CancellationToken cancellationToken) { if (createChannel.PlayoutSource is not ChannelPlayoutSource.Mirror) { return Unit.Default; } Option maybeMirrorSource = await dbContext.Channels .AsNoTracking() .SelectOneAsync( c => c.Id == createChannel.MirrorSourceChannelId, c => c.Id == createChannel.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(createChannel.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; } }