From 2ed432f9b5379b2dc6ec5bdb6a7f775c2c50464a Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:04:55 -0500 Subject: [PATCH] feat: enable ersatztv next streaming engine (#2854) * return multi variant playlist from start ffmpeg session * use ersatztv-channel session when next engine is configured * add next playout models * generate next playout json files on non-windows systems --- .../ErsatzTV.Application.csproj | 1 + .../Playouts/Commands/BuildPlayoutHandler.cs | 2 + .../Playouts/Commands/SyncNextPlayout.cs | 3 + .../Commands/SyncNextPlayoutHandler.cs | 205 ++++++++++ .../Commands/StartFFmpegNextSession.cs | 13 + .../Commands/StartFFmpegNextSessionHandler.cs | 371 ++++++++++++++++++ .../Streaming/Commands/StartFFmpegSession.cs | 10 +- .../Commands/StartFFmpegSessionHandler.cs | 67 +++- .../Streaming/NextSessionWorker.cs | 157 ++++++++ .../Errors/ChannelSessionAlreadyActive.cs | 6 +- ErsatzTV.Core/ErsatzTV.Core.csproj | 1 + ErsatzTV.Core/FileSystemLayout.cs | 4 + ErsatzTV.Core/Next/Config/ChannelConfig.cs | 350 +++++++++++++++++ ErsatzTV.Core/Next/Playout.cs | 260 ++++++++++++ ErsatzTV/Controllers/IptvController.cs | 86 ++-- ErsatzTV/Services/WorkerService.cs | 3 + ErsatzTV/Startup.cs | 3 +- 17 files changed, 1470 insertions(+), 72 deletions(-) create mode 100644 ErsatzTV.Application/Playouts/Commands/SyncNextPlayout.cs create mode 100644 ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs create mode 100644 ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSession.cs create mode 100644 ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs create mode 100644 ErsatzTV.Application/Streaming/NextSessionWorker.cs create mode 100644 ErsatzTV.Core/Next/Config/ChannelConfig.cs create mode 100644 ErsatzTV.Core/Next/Playout.cs diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj b/ErsatzTV.Application/ErsatzTV.Application.csproj index cdbbf65b0..00f38e17b 100644 --- a/ErsatzTV.Application/ErsatzTV.Application.csproj +++ b/ErsatzTV.Application/ErsatzTV.Application.csproj @@ -7,6 +7,7 @@ latest-Recommended true Debug;Release;Debug No Sync + true diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs index 95c14f813..db445a3e7 100644 --- a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs @@ -325,6 +325,8 @@ public class BuildPlayoutHandler : IRequestHandler dbContextFactory, + ILogger logger) + : IRequestHandler +{ + [LibraryImport("libc", EntryPoint = "rename", SetLastError = true)] + private static partial int Rename( + [MarshalAs(UnmanagedType.LPUTF8Str)] + string oldpath, + [MarshalAs(UnmanagedType.LPUTF8Str)] + string newpath + ); + + public async Task Handle(SyncNextPlayout request, CancellationToken cancellationToken) + { + // TODO: NEXT: support junctions on Windows + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + // gen new folder name + string versionFolderName = DateTimeOffset.Now.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture); + + string versionFolder = fileSystem.Path.Combine( + FileSystemLayout.NextPlayoutsFolder, + request.ChannelNumber, + versionFolderName); + + logger.LogDebug("versioned playout folder is {Folder}", versionFolder); + + localFileSystem.EnsureFolderExists(versionFolder); + + await WriteAllJsonTo(request.ChannelNumber, versionFolder, cancellationToken); + + string currentFolder = fileSystem.Path.Combine( + FileSystemLayout.NextPlayoutsFolder, + request.ChannelNumber, + "current"); + + // re-point symlink/junction to new folder + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + } + else + { + string tempLink = fileSystem.Path.Combine( + FileSystemLayout.NextPlayoutsFolder, + request.ChannelNumber, + fileSystem.Path.GetRandomFileName()); + + fileSystem.File.CreateSymbolicLink(tempLink, versionFolderName); + _ = Rename(tempLink, currentFolder); + } + + CleanOldVersions( + fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, request.ChannelNumber), + currentFolder); + } + + private async Task WriteAllJsonTo(string channelNumber, string targetFolder, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + List localLibraryIds = await dbContext.LocalLibraries + .AsNoTracking() + .Map(l => l.Id) + .ToListAsync(cancellationToken); + + List playoutItems = await dbContext.PlayoutItems + .AsNoTracking() + .Where(i => i.Playout.Channel.Number == channelNumber) + .Where(i => localLibraryIds.Contains(i.MediaItem.LibraryPath.LibraryId)) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Episode).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as OtherVideo).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .ToListAsync(cancellationToken); + + logger.LogDebug("Located {Count} local playout items", playoutItems.Count); + + foreach (IGrouping group in playoutItems.GroupBy(pi => pi.StartOffset.Date) + .Where(g => g.Any())) + { + var first = group.First(); + var last = group.Last(); + + string fileName = fileSystem.Path.Combine( + targetFolder, + $"{first.StartOffset.ToUnixTimeMilliseconds()}_{last.FinishOffset.ToUnixTimeMilliseconds()}.json"); + + var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.1", Items = [] }; + foreach (PlayoutItem playoutItem in group) + { + if (playoutItem.MediaItem is not Episode && playoutItem.MediaItem is not Movie && + playoutItem.MediaItem is not OtherVideo && playoutItem.MediaItem is not MusicVideo) + { + continue; + } + + string path = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head().Path; + + var nextPlayoutItem = new ItemElement + { + Id = playoutItem.Id.ToString(CultureInfo.InvariantCulture), + Start = playoutItem.StartOffset.ToString("O"), + Finish = playoutItem.FinishOffset.ToString("O"), + Source = new ItemSource + { + SourceType = SourceType.Local, + Path = path, + } + }; + + playout.Items.Add(nextPlayoutItem); + } + + await fileSystem.File.WriteAllTextAsync(fileName, playout.ToJson(), cancellationToken); + } + } + + public void CleanOldVersions( + string playoutRoot, + string currentLinkPath, + int keepVersions = 2, + TimeSpan? gracePeriod = null) + { + gracePeriod ??= TimeSpan.FromMinutes(5); + + string currentResolvedPath = null; + if (Directory.Exists(currentLinkPath)) + { + currentResolvedPath = Path.GetFullPath( + Path.Combine( + Path.GetDirectoryName(currentLinkPath) ?? "", + Directory.ResolveLinkTarget(currentLinkPath, true)?.FullName ?? "" + )); + } + + var directories = Directory.GetDirectories(playoutRoot) + .Select(d => new DirectoryInfo(d)) + .Where(d => long.TryParse(d.Name, out _)) + .OrderByDescending(d => d.Name) + .ToList(); + + int keptCount = 0; + + foreach (var dir in directories) + { + string fullDir = dir.FullName; + + if (fullDir.Equals(currentResolvedPath, StringComparison.OrdinalIgnoreCase)) + { + keptCount++; + continue; + } + + if (keptCount < keepVersions) + { + keptCount++; + continue; + } + + if (DateTime.Now - dir.LastWriteTime < gracePeriod) + { + continue; + } + + try + { + dir.Delete(recursive: true); + logger.LogDebug("Cleaned up old playout version: {Folder}", dir.Name); + } + catch (IOException) + { + // ignore errors; will be cleaned up next time through + logger.LogDebug("Skipping busy folder: {Folder}", dir.Name); + } + } + } +} diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSession.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSession.cs new file mode 100644 index 000000000..12348ea1a --- /dev/null +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSession.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Streaming; + +public record StartFFmpegNextSession( + string ChannelNumber, + string Mode, + string Scheme, + string Host, + string PathBase, + string AccessTokenQuery) : + IRequest>, + IFFmpegWorkerRequest; diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs new file mode 100644 index 000000000..913fb1c97 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs @@ -0,0 +1,371 @@ +using System.Globalization; +using System.IO.Abstractions; +using System.Reflection; +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; + +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( + request.ChannelNumber, + 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 = string.IsNullOrWhiteSpace(SystemEnvironment.NextFolder) + ? fileSystem.Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location) + : SystemEnvironment.NextFolder; + + 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}"; + + 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:3 +#EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution} +{variantPlaylist}"; + } + + private async Task MapConfig( + string channelNumber, + FFmpegProfileViewModel ffmpegProfile, + CancellationToken cancellationToken) + { + var ffmpeg = new Ffmpeg(); + + 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; + } + + 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 + }; + } + + 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.Nvenc => AccelEnum.Cuda, + HardwareAccelerationKind.Qsv => AccelEnum.Qsv, + HardwareAccelerationKind.Vaapi => AccelEnum.Vaapi, + HardwareAccelerationKind.VideoToolbox => AccelEnum.Videotoolbox, + _ => null + }, + Height = ffmpegProfile.Resolution.Height, + Width = ffmpegProfile.Resolution.Width, + BitrateKbps = ffmpegProfile.VideoBitrate, + BufferKbps = ffmpegProfile.VideoBufferSize, + // TODO: NEXT: more tonemap algorithms + TonemapAlgorithm = "linear", + VaapiDevice = ffmpegProfile.VaapiDevice, + VaapiDriver = ffmpegProfile.VaapiDriver switch + { + VaapiDriver.i965 => VaapiDriverEnum.I965, + VaapiDriver.RadeonSI => VaapiDriverEnum.Radeonsi, + _ => VaapiDriverEnum.Ihd + } + }; + + string playoutFolder = fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channelNumber, "current"); + + return new ChannelConfig + { + Playout = new Core.Next.Config.Playout + { + Folder = playoutFolder + }, + Ffmpeg = ffmpeg, + Normalization = new Normalization + { + Audio = audioNormalization, + Video = videoNormalization + } + }; + } + + private sealed record ValidationResult( + string ChannelBinary, + ChannelViewModel Channel, + FFmpegProfileViewModel FfmpegProfile); +} diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs index 499707e81..a767b8234 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs @@ -2,6 +2,12 @@ namespace ErsatzTV.Application.Streaming; -public record StartFFmpegSession(string ChannelNumber, string Mode, string Scheme, string Host) : - IRequest>, +public record StartFFmpegSession( + string ChannelNumber, + string Mode, + string Scheme, + string Host, + string PathBase, + string AccessTokenQuery) : + IRequest>, IFFmpegWorkerRequest; diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs index ecb944b0f..c8322bf96 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs @@ -1,4 +1,5 @@ -using System.IO.Abstractions; +using System.Globalization; +using System.IO.Abstractions; using System.Threading.Channels; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Graphics; @@ -19,7 +20,7 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Streaming; -public class StartFFmpegSessionHandler : IRequestHandler> +public class StartFFmpegSessionHandler : IRequestHandler> { private readonly IFileSystem _fileSystem; private readonly IConfigElementRepository _configElementRepository; @@ -65,15 +66,15 @@ public class StartFFmpegSessionHandler : IRequestHandler> Handle(StartFFmpegSession request, CancellationToken cancellationToken) => + public Task> Handle(StartFFmpegSession request, CancellationToken cancellationToken) => Validate(request) .MapT(_ => StartProcess(request, 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, Unit>(identity)); + .Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync, string>(identity)); #pragma warning restore VSTHRD103 - private async Task StartProcess(StartFFmpegSession request, CancellationToken cancellationToken) + private async Task StartProcess(StartFFmpegSession request, CancellationToken cancellationToken) { Option idleTimeout = await _configElementRepository .GetValue(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken) @@ -116,7 +117,7 @@ public class StartFFmpegSessionHandler : IRequestHandler targetFramerate) => @@ -139,12 +140,12 @@ public class StartFFmpegSessionHandler : IRequestHandler FolderMustBeEmpty(request)); - private Task> SessionMustBeInactive(StartFFmpegSession request) + private async Task> SessionMustBeInactive(StartFFmpegSession request) { var result = Optional(_ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null)) .Where(success => success) .Map(_ => Unit.Default) - .ToValidation(new ChannelSessionAlreadyActive()); + .ToValidation(new ChannelSessionAlreadyActive(await GetMultiVariantPlaylist(request))); if (result.IsFail && _ffmpegSegmenterService.TryGetWorker( request.ChannelNumber, @@ -153,7 +154,7 @@ public class StartFFmpegSessionHandler : IRequestHandler.None); } - return result.AsTask(); + return result; } private Task> FolderMustBeEmpty(StartFFmpegSession request) @@ -166,4 +167,52 @@ public class StartFFmpegSessionHandler : IRequestHandler>(Unit.Default); } + + private async Task GetMultiVariantPlaylist(StartFFmpegSession request) + { + var variantPlaylist = + $"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/hls.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:3 +#EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution} +{variantPlaylist}"; + } } diff --git a/ErsatzTV.Application/Streaming/NextSessionWorker.cs b/ErsatzTV.Application/Streaming/NextSessionWorker.cs new file mode 100644 index 000000000..3dd0bc309 --- /dev/null +++ b/ErsatzTV.Application/Streaming/NextSessionWorker.cs @@ -0,0 +1,157 @@ +using System.IO.Abstractions; +using CliWrap; +using ErsatzTV.Core; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Next.Config; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Application.Streaming; + +public class NextSessionWorker( + string channelBinary, + ChannelConfig channelConfig, + IFileSystem fileSystem, + ILocalFileSystem localFileSystem, + IServiceScopeFactory serviceScopeFactory, + ILogger logger) + : IHlsSessionWorker +{ + private readonly SemaphoreSlim _slim = new(1, 1); + private CancellationTokenSource _cancellationTokenSource; + private IServiceScope _serviceScope = serviceScopeFactory.CreateScope(); + private bool _disposedValue; + private string _channelNumber; + private string _workingDirectory; + private string _heartbeatFileName; + + void IDisposable.Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + _serviceScope.Dispose(); + _serviceScope = null; + } + + _disposedValue = true; + } + } + + public async Task Cancel(CancellationToken cancellationToken) + { + logger.LogInformation("API termination request for HLS session for channel {Channel}", _channelNumber); + + await _slim.WaitAsync(cancellationToken); + try + { + await _cancellationTokenSource.CancelAsync(); + } + finally + { + _slim.Release(); + } + } + + public void Touch(Option fileName) + { + if (!fileSystem.File.Exists(_heartbeatFileName)) + { + fileSystem.File.WriteAllBytes(_heartbeatFileName, []); + } + else + { + fileSystem.File.SetLastWriteTimeUtc(_heartbeatFileName, DateTime.UtcNow); + } + } + + public Task> TrimPlaylist( + DateTimeOffset filterBefore, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public void PlayoutUpdated() + { + // nothing to do here; channel binary should detect that by itself + } + + public HlsSessionModel GetModel() => throw new NotSupportedException(); + + public async Task Run( + string channelNumber, + Option idleTimeout, + CancellationToken incomingCancellationToken) + { + _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken); + + try + { + _channelNumber = channelNumber; + _workingDirectory = fileSystem.Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber); + _heartbeatFileName = fileSystem.Path.Combine(_workingDirectory, ".heartbeat"); + + CommandResult commandResult = await Cli.Wrap(channelBinary) + .WithArguments( + ["run", "--output-folder", _workingDirectory, "--number", channelNumber, "-"]) + .WithStandardInputPipe(PipeSource.FromString(channelConfig.ToJson())) + .WithStandardOutputPipe(PipeTarget.ToDelegate(l => logger.LogDebug("{Line}", l))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(l => logger.LogDebug("{Line}", l))) + //.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(_cancellationTokenSource.Token); + + if (commandResult.ExitCode != 0) + { + await _cancellationTokenSource.CancelAsync(); + + logger.LogError( + "ErsatzTV Next session for channel {Channel} has terminated unsuccessfully with exit code {ExitCode}", + _channelNumber, + commandResult.ExitCode); + } + else + { + logger.LogDebug("ErsatzTV Next session has completed for channel {Channel}", _channelNumber); + } + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + logger.LogInformation("Terminating ErsatzTV Next session for channel {Channel}", _channelNumber); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error running ErsatzTV Next session"); + } + finally + { + try + { + localFileSystem.EmptyFolder(_workingDirectory); + } + catch + { + // do nothing + } + } + } + + public async Task WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken) + { + string readyFileName = fileSystem.Path.Combine(_workingDirectory, ".ready"); + + logger.LogDebug("Waiting for ErsatzTV Next channel to be ready"); + while (!fileSystem.File.Exists(readyFileName)) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + } + } +} diff --git a/ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs b/ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs index 6d321d4d6..66c4cba02 100644 --- a/ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs +++ b/ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs @@ -1,8 +1,6 @@ namespace ErsatzTV.Core.Errors; -public class ChannelSessionAlreadyActive : BaseError +public class ChannelSessionAlreadyActive(string multiVariantPlaylist) : BaseError("Channel already has HLS session") { - public ChannelSessionAlreadyActive() : base("Channel already has HLS session") - { - } + public string MultiVariantPlaylist { get; } = multiVariantPlaylist; } diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj index a0a3fd7f0..7656ee9a2 100644 --- a/ErsatzTV.Core/ErsatzTV.Core.csproj +++ b/ErsatzTV.Core/ErsatzTV.Core.csproj @@ -45,6 +45,7 @@ + diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index 13e9f42ee..9cdc7ed41 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -67,6 +67,8 @@ public static class FileSystemLayout public static readonly string DefaultMpegTsScriptFolder; + public static readonly string NextPlayoutsFolder; + public static readonly string MacOsOldAppDataFolder = Path.Combine( Environment.GetEnvironmentVariable("HOME") ?? string.Empty, ".local", @@ -192,5 +194,7 @@ public static class FileSystemLayout MpegTsScriptsFolder = Path.Combine(ScriptsFolder, "mpegts"); DefaultMpegTsScriptFolder = Path.Combine(MpegTsScriptsFolder, "default"); + + NextPlayoutsFolder = Path.Combine(AppDataFolder, "next", "playouts"); } } diff --git a/ErsatzTV.Core/Next/Config/ChannelConfig.cs b/ErsatzTV.Core/Next/Config/ChannelConfig.cs new file mode 100644 index 000000000..5d0c5eeb8 --- /dev/null +++ b/ErsatzTV.Core/Next/Config/ChannelConfig.cs @@ -0,0 +1,350 @@ +// +// +// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do: +// +// using ErsatzTV.Core.Next.Config; +// +// var channelConfig = ChannelConfig.FromJson(jsonString); + +namespace ErsatzTV.Core.Next.Config +{ + using System; + using System.Collections.Generic; + + using System.Globalization; + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + public partial class ChannelConfig + { + [JsonProperty("ffmpeg")] + public Ffmpeg Ffmpeg { get; set; } + + [JsonProperty("normalization")] + public Normalization Normalization { get; set; } + + [JsonProperty("playout")] + public Playout Playout { get; set; } + } + + public partial class Ffmpeg + { + [JsonProperty("disabled_filters", NullValueHandling = NullValueHandling.Ignore)] + public List DisabledFilters { get; set; } + + [JsonProperty("ffmpeg_path")] + public string FfmpegPath { get; set; } + + [JsonProperty("ffprobe_path")] + public string FfprobePath { get; set; } + } + + public partial class Normalization + { + [JsonProperty("audio")] + public Audio Audio { get; set; } + + [JsonProperty("video")] + public Video Video { get; set; } + } + + public partial class Audio + { + [JsonProperty("bitrate_kbps")] + public long? BitrateKbps { get; set; } + + [JsonProperty("buffer_kbps")] + public long? BufferKbps { get; set; } + + [JsonProperty("channels")] + public long? Channels { get; set; } + + [JsonProperty("format")] + public AudioFormat? Format { get; set; } + + [JsonProperty("loudness")] + public LoudnessClass Loudness { get; set; } + + [JsonProperty("normalize_loudness", NullValueHandling = NullValueHandling.Ignore)] + public bool? NormalizeLoudness { get; set; } + + [JsonProperty("sample_rate_hz")] + public long? SampleRateHz { get; set; } + } + + public partial class LoudnessClass + { + [JsonProperty("integrated_target")] + public double? IntegratedTarget { get; set; } + + [JsonProperty("range_target")] + public double? RangeTarget { get; set; } + + [JsonProperty("true_peak")] + public double? TruePeak { get; set; } + } + + public partial class Video + { + [JsonProperty("accel")] + public AccelEnum? Accel { get; set; } + + [JsonProperty("bit_depth")] + public long? BitDepth { get; set; } + + [JsonProperty("bitrate_kbps")] + public long? BitrateKbps { get; set; } + + [JsonProperty("buffer_kbps")] + public long? BufferKbps { get; set; } + + [JsonProperty("format")] + public VideoFormat? Format { get; set; } + + [JsonProperty("height")] + public long? Height { get; set; } + + [JsonProperty("tonemap_algorithm")] + public string TonemapAlgorithm { get; set; } + + [JsonProperty("vaapi_device")] + public string VaapiDevice { get; set; } + + [JsonProperty("vaapi_driver")] + public VaapiDriverEnum? VaapiDriver { get; set; } + + [JsonProperty("width")] + public long? Width { get; set; } + } + + public partial class Playout + { + [JsonProperty("folder")] + public string Folder { get; set; } + + /// + /// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00 + /// + [JsonProperty("virtual_start")] + public string VirtualStart { get; set; } + } + + public enum AudioFormat { Aac, Ac3 }; + + public enum AccelEnum { Cuda, Qsv, Vaapi, Videotoolbox, Vulkan }; + + public enum VideoFormat { H264, Hevc }; + + public enum VaapiDriverEnum { I965, Ihd, Radeonsi }; + + public partial class ChannelConfig + { + public static ChannelConfig FromJson(string json) => JsonConvert.DeserializeObject(json, ErsatzTV.Core.Next.Config.Converter.Settings); + } + + public static class Serialize + { + public static string ToJson(this ChannelConfig self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Config.Converter.Settings); + } + + internal static class Converter + { + public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + DateParseHandling = DateParseHandling.None, + Converters = + { + AudioFormatConverter.Singleton, + AccelEnumConverter.Singleton, + VideoFormatConverter.Singleton, + VaapiDriverEnumConverter.Singleton, + new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } + }, + }; + } + + internal class AudioFormatConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(AudioFormat) || t == typeof(AudioFormat?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + switch (value) + { + case "aac": + return AudioFormat.Aac; + case "ac3": + return AudioFormat.Ac3; + } + throw new Exception("Cannot unmarshal type AudioFormat"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (AudioFormat)untypedValue; + switch (value) + { + case AudioFormat.Aac: + serializer.Serialize(writer, "aac"); + return; + case AudioFormat.Ac3: + serializer.Serialize(writer, "ac3"); + return; + } + throw new Exception("Cannot marshal type AudioFormat"); + } + + public static readonly AudioFormatConverter Singleton = new AudioFormatConverter(); + } + + internal class AccelEnumConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(AccelEnum) || t == typeof(AccelEnum?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + switch (value) + { + case "cuda": + return AccelEnum.Cuda; + case "qsv": + return AccelEnum.Qsv; + case "vaapi": + return AccelEnum.Vaapi; + case "videotoolbox": + return AccelEnum.Videotoolbox; + case "vulkan": + return AccelEnum.Vulkan; + } + throw new Exception("Cannot unmarshal type AccelEnum"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (AccelEnum)untypedValue; + switch (value) + { + case AccelEnum.Cuda: + serializer.Serialize(writer, "cuda"); + return; + case AccelEnum.Qsv: + serializer.Serialize(writer, "qsv"); + return; + case AccelEnum.Vaapi: + serializer.Serialize(writer, "vaapi"); + return; + case AccelEnum.Videotoolbox: + serializer.Serialize(writer, "videotoolbox"); + return; + case AccelEnum.Vulkan: + serializer.Serialize(writer, "vulkan"); + return; + } + throw new Exception("Cannot marshal type AccelEnum"); + } + + public static readonly AccelEnumConverter Singleton = new AccelEnumConverter(); + } + + internal class VideoFormatConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(VideoFormat) || t == typeof(VideoFormat?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + switch (value) + { + case "h264": + return VideoFormat.H264; + case "hevc": + return VideoFormat.Hevc; + } + throw new Exception("Cannot unmarshal type VideoFormat"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (VideoFormat)untypedValue; + switch (value) + { + case VideoFormat.H264: + serializer.Serialize(writer, "h264"); + return; + case VideoFormat.Hevc: + serializer.Serialize(writer, "hevc"); + return; + } + throw new Exception("Cannot marshal type VideoFormat"); + } + + public static readonly VideoFormatConverter Singleton = new VideoFormatConverter(); + } + + internal class VaapiDriverEnumConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(VaapiDriverEnum) || t == typeof(VaapiDriverEnum?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + switch (value) + { + case "i965": + return VaapiDriverEnum.I965; + case "ihd": + return VaapiDriverEnum.Ihd; + case "radeonsi": + return VaapiDriverEnum.Radeonsi; + } + throw new Exception("Cannot unmarshal type VaapiDriverEnum"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (VaapiDriverEnum)untypedValue; + switch (value) + { + case VaapiDriverEnum.I965: + serializer.Serialize(writer, "i965"); + return; + case VaapiDriverEnum.Ihd: + serializer.Serialize(writer, "ihd"); + return; + case VaapiDriverEnum.Radeonsi: + serializer.Serialize(writer, "radeonsi"); + return; + } + throw new Exception("Cannot marshal type VaapiDriverEnum"); + } + + public static readonly VaapiDriverEnumConverter Singleton = new VaapiDriverEnumConverter(); + } +} diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs new file mode 100644 index 000000000..44680c975 --- /dev/null +++ b/ErsatzTV.Core/Next/Playout.cs @@ -0,0 +1,260 @@ +// +// +// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do: +// +// using ErsatzTV.Core.Next; +// +// var playout = Playout.FromJson(jsonString); + +namespace ErsatzTV.Core.Next +{ + using System; + using System.Collections.Generic; + + using System.Globalization; + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + /// + /// A playout schedule for a single time window. + /// + /// Files should be named `{start}_{finish}.json` using compact ISO 8601 + /// (no separators), e.g. + /// `20260413T000000.000000000-0500_20260414T002131.620000000-0500.json`, + /// so that the channel can locate the correct file for the current time. + /// + public partial class Playout + { + [JsonProperty("items")] + public List Items { get; set; } + + /// + /// URI identifying the schema version, e.g. "https://ersatztv.org/playout/version/0.0.1" + /// + [JsonProperty("version")] + public string Version { get; set; } + } + + public partial class ItemElement + { + /// + /// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00 + /// + [JsonProperty("finish")] + public string Finish { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("source")] + public ItemSource Source { get; set; } + + /// + /// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00 + /// + [JsonProperty("start")] + public string Start { get; set; } + + [JsonProperty("tracks")] + public TracksClass Tracks { get; set; } + } + + public partial class ItemSource + { + [JsonProperty("in_point_ms")] + public long? InPointMs { get; set; } + + [JsonProperty("out_point_ms")] + public long? OutPointMs { get; set; } + + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + public string Path { get; set; } + + [JsonProperty("source_type")] + public SourceType SourceType { get; set; } + + [JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)] + public string Params { get; set; } + + /// + /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"] + /// + [JsonProperty("headers")] + public List Headers { get; set; } + + /// + /// Enable reconnect on failure (default: true) + /// + [JsonProperty("reconnect")] + public bool? Reconnect { get; set; } + + /// + /// Max reconnect delay in seconds + /// + [JsonProperty("reconnect_delay_max")] + public long? ReconnectDelayMax { get; set; } + + /// + /// Socket timeout in microseconds + /// + [JsonProperty("timeout_us")] + public long? TimeoutUs { get; set; } + + /// + /// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}" + /// + [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] + public string Uri { get; set; } + + /// + /// Custom user-agent string + /// + [JsonProperty("user_agent")] + public string UserAgent { get; set; } + } + + public partial class TracksClass + { + [JsonProperty("audio")] + public AudioClass Audio { get; set; } + + [JsonProperty("video")] + public AudioClass Video { get; set; } + } + + public partial class AudioClass + { + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public AudioSource Source { get; set; } + + [JsonProperty("stream_index", NullValueHandling = NullValueHandling.Ignore)] + public long? StreamIndex { get; set; } + } + + public partial class AudioSource + { + [JsonProperty("in_point_ms")] + public long? InPointMs { get; set; } + + [JsonProperty("out_point_ms")] + public long? OutPointMs { get; set; } + + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] + public string Path { get; set; } + + [JsonProperty("source_type")] + public SourceType SourceType { get; set; } + + [JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)] + public string Params { get; set; } + + /// + /// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"] + /// + [JsonProperty("headers")] + public List Headers { get; set; } + + /// + /// Enable reconnect on failure (default: true) + /// + [JsonProperty("reconnect")] + public bool? Reconnect { get; set; } + + /// + /// Max reconnect delay in seconds + /// + [JsonProperty("reconnect_delay_max")] + public long? ReconnectDelayMax { get; set; } + + /// + /// Socket timeout in microseconds + /// + [JsonProperty("timeout_us")] + public long? TimeoutUs { get; set; } + + /// + /// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}" + /// + [JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] + public string Uri { get; set; } + + /// + /// Custom user-agent string + /// + [JsonProperty("user_agent")] + public string UserAgent { get; set; } + } + + public enum SourceType { Http, Lavfi, Local }; + + public partial class Playout + { + public static Playout FromJson(string json) => JsonConvert.DeserializeObject(json, ErsatzTV.Core.Next.Converter.Settings); + } + + public static class Serialize + { + public static string ToJson(this Playout self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Converter.Settings); + } + + internal static class Converter + { + public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + DateParseHandling = DateParseHandling.None, + Converters = + { + SourceTypeConverter.Singleton, + new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } + }, + }; + } + + internal class SourceTypeConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(SourceType) || t == typeof(SourceType?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + switch (value) + { + case "http": + return SourceType.Http; + case "lavfi": + return SourceType.Lavfi; + case "local": + return SourceType.Local; + } + throw new Exception("Cannot unmarshal type SourceType"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (SourceType)untypedValue; + switch (value) + { + case SourceType.Http: + serializer.Serialize(writer, "http"); + return; + case SourceType.Lavfi: + serializer.Serialize(writer, "lavfi"); + return; + case SourceType.Local: + serializer.Serialize(writer, "local"); + return; + } + throw new Exception("Cannot marshal type SourceType"); + } + + public static readonly SourceTypeConverter Singleton = new SourceTypeConverter(); + } +} diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 21bf3ec93..99e66f2ab 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.Globalization; -using System.Text; using CliWrap; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Images; @@ -93,6 +91,15 @@ public class IptvController : StreamingControllerBase return NotFound(); } + foreach (ChannelViewModel channel in maybeChannel) + { + // NEXT: MPEG-TS streams are not (yet?) supported + if (!channel.IsEnabled || channel.StreamingEngine is StreamingEngine.Next) + { + return NotFound(); + } + } + // if mode is "unspecified" - find the configured mode and set it or redirect if (string.IsNullOrWhiteSpace(mode) || mode == "mixed") { @@ -224,11 +231,26 @@ public class IptvController : StreamingControllerBase "Maybe starting ffmpeg session for channel {Channel}, mode {Mode}", channelNumber, mode); - var request = new StartFFmpegSession(channelNumber, mode, Request.Scheme, Request.Host.ToString()); - Either result = await _mediator.Send(request); - string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber); + StreamingEngine streamingEngine = + await maybeChannel.Map(c => c.StreamingEngine).IfNoneAsync(StreamingEngine.Legacy); + IRequest> request = streamingEngine is StreamingEngine.Legacy + ? new StartFFmpegSession( + channelNumber, + mode, + Request.Scheme, + Request.Host.ToString(), + Request.PathBase, + AccessTokenQuery()) + : new StartFFmpegNextSession( + channelNumber, + mode, + Request.Scheme, + Request.Host.ToString(), + Request.PathBase, + AccessTokenQuery()); + Either result = await _mediator.Send(request); return result.Match( - _ => + multiVariantPlaylist => { _logger.LogDebug( "Session started; returning multi-variant playlist for channel {Channel}", @@ -241,12 +263,12 @@ public class IptvController : StreamingControllerBase { switch (error) { - case ChannelSessionAlreadyActive: + case ChannelSessionAlreadyActive active: _logger.LogDebug( "Session is already active; returning multi-variant playlist for channel {Channel}", channelNumber); - return Content(multiVariantPlaylist, "application/vnd.apple.mpegurl"); + return Content(active.MultiVariantPlaylist, "application/vnd.apple.mpegurl"); // return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8"); default: _logger.LogWarning( @@ -289,54 +311,6 @@ public class IptvController : StreamingControllerBase public async Task GetStream(string channelNumber) => await GetHlsDirectStream(channelNumber); - private async Task GetMultiVariantPlaylist(string channelNumber) - { - var variantPlaylist = - $"{Request.Scheme}://{Request.Host}{Request.PathBase}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}"; - - Option maybeStreamingSpecs = - await _mediator.Send(new GetChannelStreamingSpecs(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:3 -#EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution} -{variantPlaylist}"; - } - private async Task GetHlsDirectStream(string channelNumber) { var request = new GetPlayoutItemProcessByChannelNumber( diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs index 5e3ebfd00..03d15d26d 100644 --- a/ErsatzTV/Services/WorkerService.cs +++ b/ErsatzTV/Services/WorkerService.cs @@ -81,6 +81,9 @@ public class WorkerService : BackgroundService error.Value)); break; } + case SyncNextPlayout syncNextPlayout: + await mediator.Send(syncNextPlayout, stoppingToken); + break; case CheckForOverlappingPlayoutItems checkForOverlappingPlayoutItems: await mediator.Send(checkForOverlappingPlayoutItems, stoppingToken); break; diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 6ca87f9b8..711c1e22a 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -362,7 +362,8 @@ public class Startup FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, FileSystemLayout.AudioStreamSelectorScriptsFolder, FileSystemLayout.MpegTsScriptsFolder, - FileSystemLayout.DefaultMpegTsScriptFolder + FileSystemLayout.DefaultMpegTsScriptFolder, + FileSystemLayout.NextPlayoutsFolder, ]; foreach (string directory in directoriesToCreate)