using System.Globalization; using System.IO.Abstractions; using System.Runtime.InteropServices; using System.Threading.Channels; using ErsatzTV.Application.Channels; using ErsatzTV.Application.FFmpegProfiles; using ErsatzTV.Application.Graphics; using ErsatzTV.Application.Maintenance; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Next.Config; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Subtitle = ErsatzTV.Core.Next.Config.Subtitle; namespace ErsatzTV.Application.Streaming; public class StartFFmpegNextSessionHandler( IServiceScopeFactory serviceScopeFactory, IFileSystem fileSystem, ILocalFileSystem localFileSystem, IFFmpegSegmenterService ffmpegSegmenterService, IConfigElementRepository configElementRepository, IHostApplicationLifetime hostApplicationLifetime, IMediator mediator, ChannelWriter workerChannel, ILogger logger, ILogger sessionWorkerLogger) : IRequestHandler> { public Task> Handle( StartFFmpegNextSession request, CancellationToken cancellationToken) => Validate(request, cancellationToken) .MapT(validationResult => StartProcess(request, validationResult, cancellationToken)) // this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError) #pragma warning disable VSTHRD103 .Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync, string>(identity)); #pragma warning restore VSTHRD103 private async Task StartProcess( StartFFmpegNextSession request, ValidationResult validationResult, CancellationToken cancellationToken) { Option idleTimeout = Option.None; // Option targetFramerate = await mediator.Send( // new GetChannelFramerate(request.ChannelNumber), // cancellationToken); // only load timeout when needed if (validationResult.Channel.IdleBehavior is not ChannelIdleBehavior.KeepRunning) { idleTimeout = await configElementRepository .GetValue(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken) .Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1))); } await mediator.Send(new RefreshGraphicsElements(), cancellationToken); ChannelConfig config = await MapConfig( validationResult.Channel, validationResult.FfmpegProfile, cancellationToken); NextSessionWorker worker = new NextSessionWorker( validationResult.ChannelBinary, config, fileSystem, localFileSystem, serviceScopeFactory, sessionWorkerLogger); ffmpegSegmenterService.AddOrUpdateWorker(request.ChannelNumber, worker); // fire and forget worker _ = worker.Run(request.ChannelNumber, idleTimeout, hostApplicationLifetime.ApplicationStopping) .ContinueWith( _ => { ffmpegSegmenterService.RemoveWorker(request.ChannelNumber, out IHlsSessionWorker inactiveWorker); inactiveWorker?.Dispose(); workerChannel.TryWrite(new ReleaseMemory(false)); }, TaskScheduler.Default); int initialSegmentCount = await configElementRepository .GetValue(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken) .Map(maybeCount => maybeCount.Match(identity, () => 1)); await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken); return await GetMultiVariantPlaylist(request); } private Task> Validate( StartFFmpegNextSession request, CancellationToken cancellationToken) => SessionMustBeInactive(request) .BindT(_ => FolderMustBeEmpty(request)) .BindT(_ => ChannelBinaryMustExist()) .BindT(result => ChannelMustExist(request, result, cancellationToken)) .BindT(result => FFmpegProfileMustExist(result, cancellationToken)); private async Task> SessionMustBeInactive(StartFFmpegNextSession request) { var result = Optional(ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null)) .Where(success => success) .Map(_ => Unit.Default) .ToValidation(new ChannelSessionAlreadyActive(await GetMultiVariantPlaylist(request))); if (result.IsFail && ffmpegSegmenterService.TryGetWorker( request.ChannelNumber, out IHlsSessionWorker worker)) { worker?.Touch(Option.None); } return result; } private Task> FolderMustBeEmpty(StartFFmpegNextSession request) { string folder = Path.Combine(FileSystemLayout.TranscodeFolder, request.ChannelNumber); logger.LogDebug("Preparing transcode folder {Folder}", folder); localFileSystem.EnsureFolderExists(folder); localFileSystem.EmptyFolder(folder); return Task.FromResult>(Unit.Default); } private Task> ChannelBinaryMustExist() { string nextFolder = SystemEnvironment.NextFolder; if (string.IsNullOrWhiteSpace(nextFolder)) { string processFileName = Environment.ProcessPath ?? string.Empty; string processExecutable = Path.GetFileNameWithoutExtension(processFileName); nextFolder = Path.GetDirectoryName(processFileName); if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase)) { nextFolder = AppContext.BaseDirectory; } } string executable = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ersatztv-channel.exe" : "ersatztv-channel"; string channelBinary = fileSystem.Path.Combine(ReplaceTilde(nextFolder), executable); if (!fileSystem.Path.Exists(channelBinary)) { return Task.FromResult>( BaseError.New("ersatztv-channel binary does not exist!")); } return Task.FromResult>( new ValidationResult(channelBinary, null, null)); } private async Task> ChannelMustExist( StartFFmpegNextSession request, ValidationResult result, CancellationToken cancellationToken) { Option maybeChannel = await mediator.Send( new GetChannelByNumber(request.ChannelNumber), cancellationToken); foreach (ChannelViewModel channel in maybeChannel) { return result with { Channel = channel }; } return BaseError.New($"Channel number {request.ChannelNumber} does not exist"); } private async Task> FFmpegProfileMustExist( ValidationResult result, CancellationToken cancellationToken) { Option maybeFFmpegProfile = await mediator.Send( new GetFFmpegProfileById(result.Channel.FFmpegProfileId), cancellationToken); foreach (FFmpegProfileViewModel ffmpegProfile in maybeFFmpegProfile) { return result with { FfmpegProfile = ffmpegProfile }; } return BaseError.New($"FFmpeg profile {result.Channel.FFmpegProfileId} not exist"); } public string ReplaceTilde(string path) { if (!path.StartsWith('~')) { return path; } string userFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); switch (path) { case "~": return userFolder; case not null when path.Length == 2 && (path[1] == fileSystem.Path.DirectorySeparatorChar || path[1] == fileSystem.Path.AltDirectorySeparatorChar): return userFolder + fileSystem.Path.DirectorySeparatorChar; default: return fileSystem.Path.Combine(userFolder, path[2..]); } } private async Task GetMultiVariantPlaylist(StartFFmpegNextSession request) { var variantPlaylist = $"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/live.m3u8{request.AccessTokenQuery}"; var subtitlePlaylist = $"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/live_sub.m3u8{request.AccessTokenQuery}"; Option maybeStreamingSpecs = await mediator.Send(new GetChannelStreamingSpecs(request.ChannelNumber)); string resolution = string.Empty; var bitrate = "10000000"; foreach (ChannelStreamingSpecsViewModel streamingSpecs in maybeStreamingSpecs) { string videoCodec = streamingSpecs.VideoFormat switch { FFmpegProfileVideoFormat.Av1 => "av01.0.01M.08", FFmpegProfileVideoFormat.Hevc => "hvc1.1.6.L93.B0", FFmpegProfileVideoFormat.H264 => "avc1.4D4028", _ => string.Empty }; string audioCodec = streamingSpecs.AudioFormat switch { FFmpegProfileAudioFormat.Ac3 => "ac-3", FFmpegProfileAudioFormat.Aac or FFmpegProfileAudioFormat.AacLatm => "mp4a.40.2", _ => string.Empty }; List codecStrings = []; if (!string.IsNullOrWhiteSpace(videoCodec)) { codecStrings.Add(videoCodec); } if (!string.IsNullOrWhiteSpace(audioCodec)) { codecStrings.Add(audioCodec); } string codecs = codecStrings.Count > 0 ? $",CODECS=\"{string.Join(",", codecStrings)}\"" : string.Empty; resolution = $",RESOLUTION={streamingSpecs.Width}x{streamingSpecs.Height}{codecs}"; bitrate = streamingSpecs.Bitrate.ToString(CultureInfo.InvariantCulture); } return $@"#EXTM3U #EXT-X-VERSION:6 #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=""subs"",NAME=""English"",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE=""en"",URI=""{subtitlePlaylist}"" #EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution} {variantPlaylist}"; } private async Task MapConfig( ChannelViewModel channel, FFmpegProfileViewModel ffmpegProfile, CancellationToken cancellationToken) { var ffmpeg = new Ffmpeg { // next only keeps errors, so always pass the folder ReportsFolder = FileSystemLayout.FFmpegReportsFolder }; Option ffmpegPath = await configElementRepository.GetValue( ConfigElementKey.FFmpegPath, cancellationToken); foreach (string path in ffmpegPath) { ffmpeg.FfmpegPath = path; } Option ffprobePath = await configElementRepository.GetValue( ConfigElementKey.FFprobePath, cancellationToken); foreach (string path in ffprobePath) { ffmpeg.FfprobePath = path; } Option maybeSaveReports = await configElementRepository.GetValue( ConfigElementKey.FFmpegSaveReports, cancellationToken); var audioNormalization = new Audio { Format = ffmpegProfile.AudioFormat switch { FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3, _ => AudioFormat.Aac }, BitrateKbps = ffmpegProfile.AudioBitrate, BufferKbps = ffmpegProfile.AudioBufferSize, Channels = ffmpegProfile.AudioChannels, SampleRateHz = ffmpegProfile.AudioSampleRate * 1000 }; if (ffmpegProfile.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm) { audioNormalization.NormalizeLoudness = true; audioNormalization.Loudness = new LoudnessClass { IntegratedTarget = ffmpegProfile.TargetLoudness }; } string tonemapAlgorithm = ffmpegProfile.TonemapAlgorithm switch { FFmpegProfileTonemapAlgorithm.Clip => "clip", FFmpegProfileTonemapAlgorithm.Gamma => "gamma", FFmpegProfileTonemapAlgorithm.Reinhard => "reinhard", FFmpegProfileTonemapAlgorithm.Mobius => "mobius", FFmpegProfileTonemapAlgorithm.Hable => "hable", _ => "linear" }; var videoNormalization = new Video { Format = ffmpegProfile.VideoFormat switch { FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc, _ => VideoFormat.H264 }, BitDepth = ffmpegProfile.BitDepth switch { FFmpegProfileBitDepth.TenBit => 10, _ => 8 }, Accel = ffmpegProfile.HardwareAcceleration switch { HardwareAccelerationKind.Amf => AccelEnum.Amf, HardwareAccelerationKind.Nvenc => AccelEnum.Cuda, HardwareAccelerationKind.Qsv => AccelEnum.Qsv, HardwareAccelerationKind.Rkmpp => AccelEnum.Rkmpp, HardwareAccelerationKind.Vaapi => AccelEnum.Vaapi, HardwareAccelerationKind.VideoToolbox => AccelEnum.Videotoolbox, _ => null }, Height = ffmpegProfile.Resolution.Height, Width = ffmpegProfile.Resolution.Width, BitrateKbps = ffmpegProfile.VideoBitrate, BufferKbps = ffmpegProfile.VideoBufferSize, ScalingMode = ffmpegProfile.ScalingBehavior switch { ScalingBehavior.Stretch => ScalingMode.Stretch, ScalingBehavior.Crop => ScalingMode.Crop, _ => ScalingMode.ScaleAndPad }, Deinterlace = ffmpegProfile.DeinterlaceVideo, Filters = new Filters { Tonemap = new TonemapClass { Tonemap = tonemapAlgorithm }, TonemapOpencl = new TonemapOpenclClass { Tonemap = tonemapAlgorithm }, Libplacebo = new LibplaceboClass { Tonemapping = tonemapAlgorithm } }, VaapiDevice = ffmpegProfile.VaapiDevice, VaapiDriver = ffmpegProfile.VaapiDriver switch { VaapiDriver.i965 => VaapiDriverEnum.I965, VaapiDriver.RadeonSI => VaapiDriverEnum.Radeonsi, _ => VaapiDriverEnum.Ihd } }; var subtitleNormalization = new Subtitle { Mode = channel.NextEngineTextSubtitleMode switch { NextEngineTextSubtitleMode.Convert => Mode.Convert, _ => Mode.Burn } }; string playoutFolder = fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channel.Number, "current"); return new ChannelConfig { Playout = new Core.Next.Config.Playout { Folder = playoutFolder }, Ffmpeg = ffmpeg, Normalization = new Normalization { Audio = audioNormalization, Video = videoNormalization, Subtitle = subtitleNormalization } }; } private sealed record ValidationResult( string ChannelBinary, ChannelViewModel Channel, FFmpegProfileViewModel FfmpegProfile); }