Browse Source

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
pull/2855/head
Jason Dove 1 month ago committed by GitHub
parent
commit
2ed432f9b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      ErsatzTV.Application/ErsatzTV.Application.csproj
  2. 2
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  3. 3
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayout.cs
  4. 205
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  5. 13
      ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSession.cs
  6. 371
      ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs
  7. 10
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs
  8. 67
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  9. 157
      ErsatzTV.Application/Streaming/NextSessionWorker.cs
  10. 6
      ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs
  11. 1
      ErsatzTV.Core/ErsatzTV.Core.csproj
  12. 4
      ErsatzTV.Core/FileSystemLayout.cs
  13. 350
      ErsatzTV.Core/Next/Config/ChannelConfig.cs
  14. 260
      ErsatzTV.Core/Next/Playout.cs
  15. 86
      ErsatzTV/Controllers/IptvController.cs
  16. 3
      ErsatzTV/Services/WorkerService.cs
  17. 3
      ErsatzTV/Startup.cs

1
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Configurations>Debug;Release;Debug No Sync</Configurations>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>

2
ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs

@ -325,6 +325,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -325,6 +325,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
}
}
await _workerChannel.WriteAsync(new SyncNextPlayout(channelNumber), cancellationToken);
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
newBuildStatus.Success = true;

3
ErsatzTV.Application/Playouts/Commands/SyncNextPlayout.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record SyncNextPlayout(string ChannelNumber) : IRequest, IBackgroundServiceRequest;

205
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -0,0 +1,205 @@ @@ -0,0 +1,205 @@
using System.Globalization;
using System.IO.Abstractions;
using System.Runtime.InteropServices;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Next;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Playouts;
public partial class SyncNextPlayoutHandler(
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<SyncNextPlayoutHandler> logger)
: IRequestHandler<SyncNextPlayout>
{
[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<int> localLibraryIds = await dbContext.LocalLibraries
.AsNoTracking()
.Map(l => l.Id)
.ToListAsync(cancellationToken);
List<PlayoutItem> 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<DateTime, PlayoutItem> 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);
}
}
}
}

13
ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSession.cs

@ -0,0 +1,13 @@ @@ -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<Either<BaseError, string>>,
IFFmpegWorkerRequest;

371
ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs

@ -0,0 +1,371 @@ @@ -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<IBackgroundServiceRequest> workerChannel,
ILogger<StartFFmpegNextSessionHandler> logger,
ILogger<NextSessionWorker> sessionWorkerLogger)
: IRequestHandler<StartFFmpegNextSession, Either<BaseError, string>>
{
public Task<Either<BaseError, string>> 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<BaseError, Task<string>, string>(identity));
#pragma warning restore VSTHRD103
private async Task<string> StartProcess(
StartFFmpegNextSession request,
ValidationResult validationResult,
CancellationToken cancellationToken)
{
Option<TimeSpan> idleTimeout = Option<TimeSpan>.None;
// Option<FrameRate> 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<int>(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<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken)
.Map(maybeCount => maybeCount.Match(identity, () => 1));
await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
return await GetMultiVariantPlaylist(request);
}
private Task<Validation<BaseError, ValidationResult>> 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<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegNextSession request)
{
var result = Optional(ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null))
.Where(success => success)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelSessionAlreadyActive(await GetMultiVariantPlaylist(request)));
if (result.IsFail && ffmpegSegmenterService.TryGetWorker(
request.ChannelNumber,
out IHlsSessionWorker worker))
{
worker?.Touch(Option<string>.None);
}
return result;
}
private Task<Validation<BaseError, Unit>> 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<Validation<BaseError, Unit>>(Unit.Default);
}
private Task<Validation<BaseError, ValidationResult>> 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<Validation<BaseError, ValidationResult>>(
BaseError.New("ersatztv-channel binary does not exist!"));
}
return Task.FromResult<Validation<BaseError, ValidationResult>>(
new ValidationResult(channelBinary, null, null));
}
private async Task<Validation<BaseError, ValidationResult>> ChannelMustExist(
StartFFmpegNextSession request,
ValidationResult result,
CancellationToken cancellationToken)
{
Option<ChannelViewModel> 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<Validation<BaseError, ValidationResult>> FFmpegProfileMustExist(
ValidationResult result,
CancellationToken cancellationToken)
{
Option<FFmpegProfileViewModel> 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<string> GetMultiVariantPlaylist(StartFFmpegNextSession request)
{
var variantPlaylist =
$"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/live.m3u8{request.AccessTokenQuery}";
Option<ChannelStreamingSpecsViewModel> 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<string> 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<ChannelConfig> MapConfig(
string channelNumber,
FFmpegProfileViewModel ffmpegProfile,
CancellationToken cancellationToken)
{
var ffmpeg = new Ffmpeg();
Option<string> ffmpegPath = await configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegPath,
cancellationToken);
foreach (string path in ffmpegPath)
{
ffmpeg.FfmpegPath = path;
}
Option<string> ffprobePath = await configElementRepository.GetValue<string>(
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);
}

10
ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs

@ -2,6 +2,12 @@ @@ -2,6 +2,12 @@
namespace ErsatzTV.Application.Streaming;
public record StartFFmpegSession(string ChannelNumber, string Mode, string Scheme, string Host) :
IRequest<Either<BaseError, Unit>>,
public record StartFFmpegSession(
string ChannelNumber,
string Mode,
string Scheme,
string Host,
string PathBase,
string AccessTokenQuery) :
IRequest<Either<BaseError, string>>,
IFFmpegWorkerRequest;

67
ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs

@ -1,4 +1,5 @@ @@ -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; @@ -19,7 +20,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Streaming;
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, string>>
{
private readonly IFileSystem _fileSystem;
private readonly IConfigElementRepository _configElementRepository;
@ -65,15 +66,15 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -65,15 +66,15 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_workerChannel = workerChannel;
}
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
public Task<Either<BaseError, string>> 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<BaseError, Task<Unit>, Unit>(identity));
.Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync<BaseError, Task<string>, string>(identity));
#pragma warning restore VSTHRD103
private async Task<Unit> StartProcess(StartFFmpegSession request, CancellationToken cancellationToken)
private async Task<string> StartProcess(StartFFmpegSession request, CancellationToken cancellationToken)
{
Option<TimeSpan> idleTimeout = await _configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken)
@ -116,7 +117,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -116,7 +117,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
return Unit.Default;
return await GetMultiVariantPlaylist(request);
}
private HlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<FrameRate> targetFramerate) =>
@ -139,12 +140,12 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -139,12 +140,12 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
SessionMustBeInactive(request)
.BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
private async Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
{
var result = Optional(_ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null))
.Where(success => success)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelSessionAlreadyActive());
.ToValidation<BaseError>(new ChannelSessionAlreadyActive(await GetMultiVariantPlaylist(request)));
if (result.IsFail && _ffmpegSegmenterService.TryGetWorker(
request.ChannelNumber,
@ -153,7 +154,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -153,7 +154,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
worker?.Touch(Option<string>.None);
}
return result.AsTask();
return result;
}
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
@ -166,4 +167,52 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -166,4 +167,52 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default);
}
private async Task<string> GetMultiVariantPlaylist(StartFFmpegSession request)
{
var variantPlaylist =
$"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/hls.m3u8{request.AccessTokenQuery}";
Option<ChannelStreamingSpecsViewModel> 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<string> 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}";
}
}

157
ErsatzTV.Application/Streaming/NextSessionWorker.cs

@ -0,0 +1,157 @@ @@ -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<NextSessionWorker> 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<string> fileName)
{
if (!fileSystem.File.Exists(_heartbeatFileName))
{
fileSystem.File.WriteAllBytes(_heartbeatFileName, []);
}
else
{
fileSystem.File.SetLastWriteTimeUtc(_heartbeatFileName, DateTime.UtcNow);
}
}
public Task<Option<TrimPlaylistResult>> 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<TimeSpan> 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);
}
}
}

6
ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs

@ -1,8 +1,6 @@ @@ -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;
}

1
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -45,6 +45,7 @@ @@ -45,6 +45,7 @@
<ItemGroup>
<Folder Include="Api\" />
<Folder Include="Next\Config\" />
</ItemGroup>
</Project>

4
ErsatzTV.Core/FileSystemLayout.cs

@ -67,6 +67,8 @@ public static class FileSystemLayout @@ -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 @@ -192,5 +194,7 @@ public static class FileSystemLayout
MpegTsScriptsFolder = Path.Combine(ScriptsFolder, "mpegts");
DefaultMpegTsScriptFolder = Path.Combine(MpegTsScriptsFolder, "default");
NextPlayoutsFolder = Path.Combine(AppDataFolder, "next", "playouts");
}
}

350
ErsatzTV.Core/Next/Config/ChannelConfig.cs

@ -0,0 +1,350 @@ @@ -0,0 +1,350 @@
// <auto-generated />
//
// 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<string> 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; }
/// <summary>
/// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00
/// </summary>
[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<ChannelConfig>(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<string>(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<string>(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<string>(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<string>(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();
}
}

260
ErsatzTV.Core/Next/Playout.cs

@ -0,0 +1,260 @@ @@ -0,0 +1,260 @@
// <auto-generated />
//
// 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;
/// <summary>
/// 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.
/// </summary>
public partial class Playout
{
[JsonProperty("items")]
public List<ItemElement> Items { get; set; }
/// <summary>
/// URI identifying the schema version, e.g. "https://ersatztv.org/playout/version/0.0.1"
/// </summary>
[JsonProperty("version")]
public string Version { get; set; }
}
public partial class ItemElement
{
/// <summary>
/// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00
/// </summary>
[JsonProperty("finish")]
public string Finish { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("source")]
public ItemSource Source { get; set; }
/// <summary>
/// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00
/// </summary>
[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; }
/// <summary>
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]
/// </summary>
[JsonProperty("headers")]
public List<string> Headers { get; set; }
/// <summary>
/// Enable reconnect on failure (default: true)
/// </summary>
[JsonProperty("reconnect")]
public bool? Reconnect { get; set; }
/// <summary>
/// Max reconnect delay in seconds
/// </summary>
[JsonProperty("reconnect_delay_max")]
public long? ReconnectDelayMax { get; set; }
/// <summary>
/// Socket timeout in microseconds
/// </summary>
[JsonProperty("timeout_us")]
public long? TimeoutUs { get; set; }
/// <summary>
/// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}"
/// </summary>
[JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)]
public string Uri { get; set; }
/// <summary>
/// Custom user-agent string
/// </summary>
[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; }
/// <summary>
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]
/// </summary>
[JsonProperty("headers")]
public List<string> Headers { get; set; }
/// <summary>
/// Enable reconnect on failure (default: true)
/// </summary>
[JsonProperty("reconnect")]
public bool? Reconnect { get; set; }
/// <summary>
/// Max reconnect delay in seconds
/// </summary>
[JsonProperty("reconnect_delay_max")]
public long? ReconnectDelayMax { get; set; }
/// <summary>
/// Socket timeout in microseconds
/// </summary>
[JsonProperty("timeout_us")]
public long? TimeoutUs { get; set; }
/// <summary>
/// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}"
/// </summary>
[JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)]
public string Uri { get; set; }
/// <summary>
/// Custom user-agent string
/// </summary>
[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<Playout>(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<string>(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();
}
}

86
ErsatzTV/Controllers/IptvController.cs

@ -1,6 +1,4 @@ @@ -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 @@ -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 @@ -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<BaseError, Unit> result = await _mediator.Send(request);
string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber);
StreamingEngine streamingEngine =
await maybeChannel.Map(c => c.StreamingEngine).IfNoneAsync(StreamingEngine.Legacy);
IRequest<Either<BaseError, string>> 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<BaseError, string> result = await _mediator.Send(request);
return result.Match<IActionResult>(
_ =>
multiVariantPlaylist =>
{
_logger.LogDebug(
"Session started; returning multi-variant playlist for channel {Channel}",
@ -241,12 +263,12 @@ public class IptvController : StreamingControllerBase @@ -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 @@ -289,54 +311,6 @@ public class IptvController : StreamingControllerBase
public async Task<IActionResult> GetStream(string channelNumber) =>
await GetHlsDirectStream(channelNumber);
private async Task<string> GetMultiVariantPlaylist(string channelNumber)
{
var variantPlaylist =
$"{Request.Scheme}://{Request.Host}{Request.PathBase}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}";
Option<ChannelStreamingSpecsViewModel> 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<string> 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<IActionResult> GetHlsDirectStream(string channelNumber)
{
var request = new GetPlayoutItemProcessByChannelNumber(

3
ErsatzTV/Services/WorkerService.cs

@ -81,6 +81,9 @@ public class WorkerService : BackgroundService @@ -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;

3
ErsatzTV/Startup.cs

@ -362,7 +362,8 @@ public class Startup @@ -362,7 +362,8 @@ public class Startup
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
FileSystemLayout.AudioStreamSelectorScriptsFolder,
FileSystemLayout.MpegTsScriptsFolder,
FileSystemLayout.DefaultMpegTsScriptFolder
FileSystemLayout.DefaultMpegTsScriptFolder,
FileSystemLayout.NextPlayoutsFolder,
];
foreach (string directory in directoriesToCreate)

Loading…
Cancel
Save