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