Browse Source

add hls segmenter v2 streaming mode (#1620)

* concat segmenter process kind of works

* segmenter v2 improvements

* rework to allow hw accel in concat segmenter

* remove shortest; use different audio alignment filter

* hls v2 improvements

* fix tests

* update changelog
pull/1621/head
Jason Dove 2 years ago committed by GitHub
parent
commit
35817f09ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/Channels/Mapper.cs
  3. 4
      ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs
  4. 2
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs
  5. 90
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  6. 84
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  7. 411
      ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs
  8. 1
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs
  9. 2
      ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumber.cs
  10. 2
      ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumberHandler.cs
  11. 19
      ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs
  12. 37
      ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumberHandler.cs
  13. 3
      ErsatzTV.Core/Domain/StreamingMode.cs
  14. 6
      ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs
  15. 152
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  16. 38
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  17. 6
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  18. 7
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  19. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IHlsSessionWorker.cs
  20. 15
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  21. 12
      ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs
  22. 8
      ErsatzTV.FFmpeg/Encoder/EncoderPcmS16Le.cs
  23. 13
      ErsatzTV.FFmpeg/Encoder/EncoderRawVideo.cs
  24. 28
      ErsatzTV.FFmpeg/FFmpegState.cs
  25. 12
      ErsatzTV.FFmpeg/Filter/AudioFirstPtsFilter.cs
  26. 16
      ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs
  27. 2
      ErsatzTV.FFmpeg/Filter/ComplexFilter.cs
  28. 8
      ErsatzTV.FFmpeg/Filter/RealtimeFilter.cs
  29. 1
      ErsatzTV.FFmpeg/Format/VideoFormat.cs
  30. 38
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatConcatHls.cs
  31. 11
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs
  32. 4
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatKind.cs
  33. 6
      ErsatzTV.FFmpeg/OutputOption/NoBFramesOutputOption.cs
  34. 6
      ErsatzTV.FFmpeg/OutputOption/ShortestOutputOption.cs
  35. 4
      ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs
  36. 1
      ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs
  37. 1
      ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs
  38. 6
      ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs
  39. 170
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  40. 7
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs
  41. 6
      ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs
  42. 6
      ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs
  43. 13
      ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs
  44. 4
      ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs
  45. 164
      ErsatzTV/Controllers/InternalController.cs
  46. 25
      ErsatzTV/Controllers/IptvController.cs
  47. 1
      ErsatzTV/Pages/ChannelEditor.razor
  48. 1
      ErsatzTV/Pages/Channels.razor
  49. 2
      ErsatzTV/Startup.cs

3
CHANGELOG.md

@ -26,6 +26,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -26,6 +26,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Image library items currently default to a duration of 15 seconds
- The `Media` > `Images` page can be used to configure image durations at a folder level
- Child folders with unset durations will inherit the closest ancestor's duration
- Add *experimental* new streaming mode `HLS Segmenter V2`
- In my initial testing, this streaming mode produces significantly fewer playback warnings/errors
- If it tests well for others, it *may* replace the current `HLS Segmenter` in a future release
### Fixed
- Fix antiforgery error caused by reusing existing browser tabs across docker container restarts

1
ErsatzTV.Application/Channels/Mapper.cs

@ -48,6 +48,7 @@ internal static class Mapper @@ -48,6 +48,7 @@ internal static class Mapper
StreamingMode.TransportStreamHybrid => "MPEG-TS",
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};
}

4
ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs

@ -34,6 +34,10 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha @@ -34,6 +34,10 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "segmenter-v2":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
result.Add(channel);
break;
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);

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

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

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

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
using System.Diagnostics;
using System.Threading.Channels;
using System.Threading.Channels;
using Bugsnag;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Maintenance;
@ -28,6 +27,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -28,6 +27,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
private readonly IMediator _mediator;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<HlsSessionWorker> _sessionWorkerLogger;
private readonly ILogger<HlsSessionWorkerV2> _sessionWorkerV2Logger;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public StartFFmpegSessionHandler(
@ -38,6 +38,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -38,6 +38,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger,
ILogger<HlsSessionWorker> sessionWorkerLogger,
ILogger<HlsSessionWorkerV2> sessionWorkerV2Logger,
IFFmpegSegmenterService ffmpegSegmenterService,
IConfigElementRepository configElementRepository,
IHostApplicationLifetime hostApplicationLifetime,
@ -50,6 +51,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -50,6 +51,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_localFileSystem = localFileSystem;
_logger = logger;
_sessionWorkerLogger = sessionWorkerLogger;
_sessionWorkerV2Logger = sessionWorkerV2Logger;
_ffmpegSegmenterService = ffmpegSegmenterService;
_configElementRepository = configElementRepository;
_hostApplicationLifetime = hostApplicationLifetime;
@ -74,14 +76,8 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -74,14 +76,8 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
new GetChannelFramerate(request.ChannelNumber),
cancellationToken);
var worker = new HlsSessionWorker(
_serviceScopeFactory,
_client,
_hlsPlaylistFilter,
_configElementRepository,
_localFileSystem,
_sessionWorkerLogger,
targetFramerate);
IHlsSessionWorker worker = GetSessionWorker(request, targetFramerate);
_ffmpegSegmenterService.AddOrUpdateWorker(request.ChannelNumber, worker);
// fire and forget worker
@ -97,69 +93,35 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -97,69 +93,35 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
},
TaskScheduler.Default);
string playlistFileName = Path.Combine(
FileSystemLayout.TranscodeFolder,
request.ChannelNumber,
"live.m3u8");
int initialSegmentCount = await _configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount)
.Map(maybeCount => maybeCount.Match(identity, () => 1));
await WaitForPlaylistSegments(playlistFileName, initialSegmentCount, worker, cancellationToken);
await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
return Unit.Default;
}
private async Task WaitForPlaylistSegments(
string playlistFileName,
int initialSegmentCount,
HlsSessionWorker worker,
CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
try
{
DateTimeOffset start = DateTimeOffset.Now;
DateTimeOffset finish = start.AddSeconds(8);
_logger.LogDebug("Waiting for playlist to exist");
while (!File.Exists(playlistFileName))
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
_logger.LogDebug("Playlist exists");
var segmentCount = 0;
int lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
{
if (segmentCount != lastSegmentCount)
{
lastSegmentCount = segmentCount;
_logger.LogDebug(
"Segment count {SegmentCount} of {InitialSegmentCount}",
segmentCount,
initialSegmentCount);
}
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30);
Option<TrimPlaylistResult> maybeResult = await worker.TrimPlaylist(now, cancellationToken);
foreach (TrimPlaylistResult result in maybeResult)
{
segmentCount = result.SegmentCount;
}
}
}
finally
private IHlsSessionWorker GetSessionWorker(StartFFmpegSession request, Option<int> targetFramerate) =>
request.Mode switch
{
sw.Stop();
_logger.LogDebug("WaitForPlaylistSegments took {Duration}", sw.Elapsed);
}
}
"segmenter-v2" => new HlsSessionWorkerV2(
_serviceScopeFactory,
_configElementRepository,
_localFileSystem,
_sessionWorkerV2Logger,
targetFramerate,
request.Scheme,
request.Host),
_ => new HlsSessionWorker(
_serviceScopeFactory,
_client,
_hlsPlaylistFilter,
_configElementRepository,
_localFileSystem,
_sessionWorkerLogger,
targetFramerate)
};
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
SessionMustBeInactive(request)

84
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -149,19 +149,6 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -149,19 +149,6 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
[SuppressMessage("Usage", "VSTHRD100:Avoid async void methods")]
async void Cancel(object o, ElapsedEventArgs e)
{
try
{
await _cancellationTokenSource.CancelAsync();
}
catch (Exception)
{
// do nothing
}
}
try
{
_channelNumber = channelNumber;
@ -169,7 +156,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -169,7 +156,7 @@ public class HlsSessionWorker : IHlsSessionWorker
lock (_sync)
{
_timer = new Timer(idleTimeout.TotalMilliseconds) { AutoReset = false };
_timer.Elapsed += Cancel;
_timer.Elapsed += CancelRun;
}
CancellationToken cancellationToken = _cancellationTokenSource.Token;
@ -225,7 +212,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -225,7 +212,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{
lock (_sync)
{
_timer.Elapsed -= Cancel;
_timer.Elapsed -= CancelRun;
}
try
@ -237,6 +224,73 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -237,6 +224,73 @@ public class HlsSessionWorker : IHlsSessionWorker
// do nothing
}
}
return;
[SuppressMessage("Usage", "VSTHRD100:Avoid async void methods")]
async void CancelRun(object o, ElapsedEventArgs e)
{
try
{
await _cancellationTokenSource.CancelAsync();
}
catch (Exception)
{
// do nothing
}
}
}
public async Task WaitForPlaylistSegments(
int initialSegmentCount,
CancellationToken cancellationToken)
{
_logger.LogDebug("Waiting for playlist segments...");
var sw = Stopwatch.StartNew();
try
{
DateTimeOffset start = DateTimeOffset.Now;
DateTimeOffset finish = start.AddSeconds(8);
string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber, "live.m3u8");
_logger.LogDebug("Waiting for playlist to exist");
while (!_localFileSystem.FileExists(playlistFileName))
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
_logger.LogDebug("Playlist exists");
var segmentCount = 0;
int lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
{
if (segmentCount != lastSegmentCount)
{
lastSegmentCount = segmentCount;
_logger.LogDebug(
"Segment count {SegmentCount} of {InitialSegmentCount}",
segmentCount,
initialSegmentCount);
}
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30);
Option<TrimPlaylistResult> maybeResult = await TrimPlaylist(now, cancellationToken);
foreach (TrimPlaylistResult result in maybeResult)
{
segmentCount = result.SegmentCount;
}
}
}
finally
{
sw.Stop();
_logger.LogDebug("WaitForPlaylistSegments took {Duration}", sw.Elapsed);
}
}
protected virtual void Dispose(bool disposing)

411
ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs

@ -0,0 +1,411 @@ @@ -0,0 +1,411 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Timers;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorkerV2 : IHlsSessionWorker
{
private static readonly SemaphoreSlim Slim = new(1, 1);
//private static int _workAheadCount;
private readonly IConfigElementRepository _configElementRepository;
private readonly string _host;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<HlsSessionWorkerV2> _logger;
private readonly IMediator _mediator;
private readonly string _scheme;
private readonly object _sync = new();
private readonly Option<int> _targetFramerate;
private CancellationTokenSource _cancellationTokenSource;
private string _channelNumber;
private bool _disposedValue;
private bool _hasWrittenSegments;
private DateTimeOffset _lastAccess;
private IServiceScope _serviceScope;
private HlsSessionState _state;
private Timer _timer;
private DateTimeOffset _transcodedUntil;
private Option<PlayoutItemProcessModel> _lastProcessModel;
public HlsSessionWorkerV2(
IServiceScopeFactory serviceScopeFactory,
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
ILogger<HlsSessionWorkerV2> logger,
Option<int> targetFramerate,
string scheme,
string host)
{
_serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
_logger = logger;
_targetFramerate = targetFramerate;
_scheme = scheme;
_host = host;
}
public DateTimeOffset PlaylistStart { get; private set; }
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()
{
lock (_sync)
{
//_logger.LogDebug("Keep alive - session worker v2 for channel {ChannelNumber}", _channelNumber);
_lastAccess = DateTimeOffset.Now;
_timer?.Stop();
_timer?.Start();
}
}
public Task<Option<TrimPlaylistResult>> TrimPlaylist(
DateTimeOffset filterBefore,
CancellationToken cancellationToken)
{
return Task.FromResult(Option<TrimPlaylistResult>.None);
}
public void PlayoutUpdated() => _state = HlsSessionState.PlayoutUpdated;
public HlsSessionModel GetModel() => new(_channelNumber, _state.ToString(), _transcodedUntil, _lastAccess);
void IDisposable.Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
public async Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken)
{
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
try
{
_channelNumber = channelNumber;
lock (_sync)
{
_timer = new Timer(idleTimeout.TotalMilliseconds) { AutoReset = false };
_timer.Elapsed += CancelRun;
}
CancellationToken cancellationToken = _cancellationTokenSource.Token;
_logger.LogInformation("Starting HLS V2 session for channel {Channel}", channelNumber);
if (_localFileSystem.ListFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber)).Any())
{
_logger.LogError("Transcode folder is NOT empty!");
}
Touch();
_transcodedUntil = DateTimeOffset.Now;
PlaylistStart = _transcodedUntil;
// start concat/segmenter process
// other transcode processes will be started by incoming requests from concat/segmenter process
var request = new GetConcatSegmenterProcessByChannelNumber(_scheme, _host, _channelNumber);
Either<BaseError, PlayoutItemProcessModel> maybeSegmenterProcess =
await _mediator.Send(request, cancellationToken);
foreach (BaseError error in maybeSegmenterProcess.LeftToSeq())
{
_logger.LogError(
"Failed to start concat segmenter for channel {ChannelNumber}: {Error}",
_channelNumber,
error.ToString());
return;
}
foreach (PlayoutItemProcessModel processModel in maybeSegmenterProcess.RightAsEnumerable())
{
Command process = processModel.Process;
_logger.LogDebug("ffmpeg concat segmenter arguments {FFmpegArguments}", process.Arguments);
try
{
BufferedCommandResult commandResult = await process
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// TODO: handle result? this will probably *always* be canceled
_logger.LogDebug("ffmpeg concat segmenter finished (canceled)");
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unexpected error in HLS Session Worker V2");
}
finally
{
lock (_sync)
{
_timer.Elapsed -= CancelRun;
}
try
{
_localFileSystem.EmptyFolder(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber));
}
catch
{
// do nothing
}
}
return;
[SuppressMessage("Usage", "VSTHRD100:Avoid async void methods")]
async void CancelRun(object o, ElapsedEventArgs e)
{
try
{
await _cancellationTokenSource.CancelAsync();
}
catch (Exception)
{
// do nothing
}
}
}
public async Task WaitForPlaylistSegments(
int initialSegmentCount,
CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
try
{
DateTimeOffset start = DateTimeOffset.Now;
DateTimeOffset finish = start.AddSeconds(8);
string segmentFolder = Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber);
string playlistFileName = Path.Combine(segmentFolder, "live.m3u8");
_logger.LogDebug("Waiting for playlist to exist");
while (!_localFileSystem.FileExists(playlistFileName))
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
_logger.LogDebug("Playlist exists");
var segmentCount = 0;
int lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
{
if (segmentCount != lastSegmentCount)
{
lastSegmentCount = segmentCount;
_logger.LogDebug(
"Segment count {SegmentCount} of {InitialSegmentCount}",
segmentCount,
initialSegmentCount);
}
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
segmentCount = _localFileSystem.ListFiles(segmentFolder, "*.ts").Count();
}
}
finally
{
sw.Stop();
_logger.LogDebug("WaitForPlaylistSegments took {Duration}", sw.Elapsed);
}
}
public async Task<Either<BaseError, PlayoutItemProcessModel>> GetNextPlayoutItemProcess()
{
foreach (PlayoutItemProcessModel processModel in _lastProcessModel)
{
_state = NextState(_state, processModel);
}
// if we're at least 30 seconds ahead, drop to realtime
var transcodedBuffer = TimeSpan.FromSeconds(
Math.Max(0, _transcodedUntil.Subtract(DateTimeOffset.Now).TotalSeconds));
if (transcodedBuffer >= TimeSpan.FromSeconds(30))
{
// throttle to realtime if needed
HlsSessionState nextState = _state switch
{
HlsSessionState.SeekAndWorkAhead => HlsSessionState.SeekAndRealtime,
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndRealtime,
_ => _state
};
if (nextState != _state)
{
_logger.LogDebug("HLS session state throttling {Last} => {Next}", _state, nextState);
_state = nextState;
}
}
_logger.LogDebug("Getting next playout item process with state {@State}", _state);
//long ptsOffset = await GetPtsOffset(_channelNumber, CancellationToken.None);
bool startAtZero = _state is HlsSessionState.ZeroAndRealtime or HlsSessionState.ZeroAndWorkAhead;
bool realtime = _state is HlsSessionState.ZeroAndRealtime or HlsSessionState.SeekAndRealtime;
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
"segmenter-v2",
_transcodedUntil,
startAtZero,
realtime,
0,
_targetFramerate);
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
{
_hasWrittenSegments = true;
_logger.LogDebug("Next playout item process will transcode until {Until}", processModel.Until);
_transcodedUntil = processModel.Until;
_lastProcessModel = processModel;
}
if (result.IsLeft)
{
_lastProcessModel = Option<PlayoutItemProcessModel>.None;
}
return result;
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_timer.Dispose();
_timer = null;
_serviceScope.Dispose();
_serviceScope = null;
}
_disposedValue = true;
}
}
private HlsSessionState NextState(HlsSessionState state, PlayoutItemProcessModel processModel)
{
bool isComplete = processModel?.IsComplete == true;
HlsSessionState result = state switch
{
// playout updates should have the channel start over, transcode method will throttle if needed
HlsSessionState.PlayoutUpdated => HlsSessionState.SeekAndWorkAhead,
// after seeking and NOT completing the item, seek again, transcode method will throttle if needed
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndWorkAhead,
// after seeking and completing the item, start at zero
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
// after starting and zero and NOT completing the item, seek, transcode method will throttle if needed
HlsSessionState.ZeroAndWorkAhead when !isComplete => HlsSessionState.SeekAndWorkAhead,
// after starting at zero and completing the item, start at zero again, transcode method will throttle if needed
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
// realtime will always complete items, so start next at zero
HlsSessionState.SeekAndRealtime => HlsSessionState.ZeroAndRealtime,
// realtime will always complete items, so start next at zero
HlsSessionState.ZeroAndRealtime => HlsSessionState.ZeroAndRealtime,
// this will never happen with the enum
_ => throw new InvalidOperationException()
};
_logger.LogDebug("HLS session state {Last} => {Next}", state, result);
return result;
}
private async Task<long> GetPtsOffset(string channelNumber, CancellationToken cancellationToken)
{
await Slim.WaitAsync(cancellationToken);
try
{
long result = 0;
// if we haven't yet written any segments, start at zero
if (!_hasWrittenSegments)
{
return result;
}
Either<BaseError, PtsAndDuration> queryResult = await _mediator.Send(
new GetLastPtsDuration(channelNumber),
cancellationToken);
foreach (BaseError error in queryResult.LeftToSeq())
{
_logger.LogWarning("Unable to determine last pts offset - {Error}", error.ToString());
}
foreach ((long pts, long duration) in queryResult.RightToSeq())
{
result = pts + duration + 1;
}
return result;
}
finally
{
Slim.Release();
}
}
private async Task<int> GetWorkAheadLimit() =>
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters)
.Map(maybeCount => maybeCount.Match(identity, () => 1));
private sealed record Segment(string File, int SequenceNumber);
}

1
ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs

@ -52,6 +52,7 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr @@ -52,6 +52,7 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"segmenter-v2" => StreamingMode.HttpLiveStreamingSegmenterV2,
"ts" => StreamingMode.TransportStreamHybrid,
"ts-legacy" => StreamingMode.TransportStream,
_ => channel.StreamingMode

2
ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumber.cs

@ -4,4 +4,4 @@ using ErsatzTV.Core.FFmpeg; @@ -4,4 +4,4 @@ using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.Streaming;
public record GetConcatPlaylistByChannelNumber
(string Scheme, string Host, string ChannelNumber) : IRequest<Either<BaseError, ConcatPlaylist>>;
(string Scheme, string Host, string ChannelNumber, string Mode) : IRequest<Either<BaseError, ConcatPlaylist>>;

2
ErsatzTV.Application/Streaming/Queries/GetConcatPlaylistByChannelNumberHandler.cs

@ -18,7 +18,7 @@ public class @@ -18,7 +18,7 @@ public class
GetConcatPlaylistByChannelNumber request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(channel => new ConcatPlaylist(request.Scheme, request.Host, channel.Number))
.MapT(channel => new ConcatPlaylist(request.Scheme, request.Host, channel.Number, request.Mode))
.Map(v => v.ToEither<ConcatPlaylist>());
private Task<Validation<BaseError, Channel>> Validate(GetConcatPlaylistByChannelNumber request) =>

19
ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
namespace ErsatzTV.Application.Streaming;
public record GetConcatSegmenterProcessByChannelNumber : FFmpegProcessRequest
{
public GetConcatSegmenterProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts-legacy",
DateTimeOffset.Now,
false,
true,
0)
{
Scheme = scheme;
Host = host;
}
public string Scheme { get; }
public string Host { get; }
}

37
ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumberHandler.cs

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming;
public class GetConcatSegmenterProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: FFmpegProcessHandler<GetConcatSegmenterProcessByChannelNumber>(dbContextFactory)
{
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetConcatSegmenterProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Command process = await ffmpegProcessService.ConcatSegmenterChannel(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, Option<TimeSpan>.None, DateTimeOffset.MaxValue, true);
}
}

3
ErsatzTV.Core/Domain/StreamingMode.cs

@ -7,5 +7,6 @@ public enum StreamingMode @@ -7,5 +7,6 @@ public enum StreamingMode
// HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4,
TransportStreamHybrid = 5
TransportStreamHybrid = 5,
HttpLiveStreamingSegmenterV2 = 6
}

6
ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs

@ -1,9 +1,9 @@ @@ -1,9 +1,9 @@
namespace ErsatzTV.Core.FFmpeg;
public record ConcatPlaylist(string Scheme, string Host, string ChannelNumber)
public record ConcatPlaylist(string Scheme, string Host, string ChannelNumber, string Mode)
{
public override string ToString() =>
$@"ffconcat version 1.0
file http://localhost:{Settings.ListenPort}/ffmpeg/stream/{ChannelNumber}?mode=ts-legacy
file http://localhost:{Settings.ListenPort}/ffmpeg/stream/{ChannelNumber}?mode=ts-legacy";
file http://localhost:{Settings.ListenPort}/ffmpeg/stream/{ChannelNumber}?mode={Mode}
file http://localhost:{Settings.ListenPort}/ffmpeg/stream/{ChannelNumber}?mode={Mode}";
}

152
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -202,7 +202,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -202,7 +202,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
// when no audio streams are available, use null audio source
if (!audioVersion.MediaVersion.Streams.Any(s => s.MediaStreamKind is MediaStreamKind.Audio))
{
audioInputFile = new NullAudioInputFile(audioState);
audioInputFile = new NullAudioInputFile(audioState with { AudioDuration = playbackSettings.AudioDuration });
}
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
@ -211,6 +211,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -211,6 +211,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
case StreamingMode.HttpLiveStreamingSegmenter:
outputFormat = OutputFormatKind.Hls;
break;
case StreamingMode.HttpLiveStreamingSegmenterV2:
outputFormat = OutputFormatKind.Nut;
break;
case StreamingMode.HttpLiveStreamingDirect:
{
// use mpeg-ts by default
@ -380,6 +383,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -380,6 +383,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
audioInputFile,
watermarkInputFile,
subtitleInputFile,
Option<ConcatInputFile>.None,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
FileSystemLayout.FFmpegReportsFolder,
@ -527,6 +531,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -527,6 +531,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
audioInputFile,
None,
subtitleInputFile,
Option<ConcatInputFile>.None,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
FileSystemLayout.FFmpegReportsFolder,
@ -548,7 +553,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -548,7 +553,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
var concatInputFile = new ConcatInputFile(
$"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}",
$"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}?mode=ts-legacy",
resolution);
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
@ -557,6 +562,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -557,6 +562,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
None,
None,
concatInputFile,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
@ -570,6 +576,146 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -570,6 +576,146 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
}
public async Task<Command> ConcatSegmenterChannel(
string ffmpegPath,
bool saveReports,
Channel channel,
string scheme,
string host)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
var concatInputFile = new ConcatInputFile(
$"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}?mode=segmenter-v2",
resolution);
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateConcatSegmenterSettings(
channel.FFmpegProfile,
Option<int>.None);
playbackSettings.AudioDuration = Option<TimeSpan>.None;
string audioFormat = playbackSettings.AudioFormat switch
{
FFmpegProfileAudioFormat.Aac => AudioFormat.Aac,
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
FFmpegProfileAudioFormat.Copy => AudioFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected audio format {playbackSettings.VideoFormat}")
};
var audioState = new AudioState(
audioFormat,
playbackSettings.AudioChannels,
playbackSettings.AudioBitrate,
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
Option<TimeSpan>.None,
playbackSettings.NormalizeLoudnessMode switch
{
// TODO: NormalizeLoudnessMode.LoudNorm => AudioFilter.LoudNorm,
_ => AudioFilter.None
});
IPixelFormat pixelFormat = channel.FFmpegProfile.VideoBitrate switch
{
8 => new PixelFormatYuv420P(), // PixelFormatNv12(PixelFormat.YUV420P),
10 => new PixelFormatYuv420P10Le(), // TODO: does 10 bit work?
_ => new PixelFormatUnknown(channel.FFmpegProfile.VideoBitrate)
};
var ffmpegVideoStream = new VideoStream(
Index: 0,
Codec: string.Empty,
Some(pixelFormat),
ColorParams.Default,
resolution,
MaybeSampleAspectRatio: "1:1",
DisplayAspectRatio: string.Empty,
FrameRate: Option<string>.None,
StillImage: false,
ScanKind.Progressive);
var videoInputFile = new VideoInputFile(concatInputFile.Url, new List<VideoStream> { ffmpegVideoStream });
var ffmpegAudioStream = new AudioStream(Index: 1, Codec: string.Empty, channel.FFmpegProfile.AudioChannels);
Option<AudioInputFile> audioInputFile = new AudioInputFile(
concatInputFile.Url,
new List<AudioStream> { ffmpegAudioStream },
audioState);
Option<SubtitleInputFile> subtitleInputFile = Option<SubtitleInputFile>.None;
Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None;
string videoFormat = GetVideoFormat(playbackSettings);
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, FillerKind.None);
Option<string> hlsPlaylistPath = Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8");
Option<string> hlsSegmentTemplate = Path.Combine(
FileSystemLayout.TranscodeFolder,
channel.Number,
"live%06d.ts");
var desiredState = new FrameState(
playbackSettings.RealtimeOutput,
InfiniteLoop: true,
videoFormat,
VideoProfile: Option<string>.None,
Optional(playbackSettings.PixelFormat),
ScaledSize: resolution,
PaddedSize: resolution,
CroppedSize: Option<FrameSize>.None,
false,
playbackSettings.FrameRate,
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale,
playbackSettings.Deinterlace);
Option<string> vaapiDriver = VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver);
Option<string> vaapiDevice = VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice);
var ffmpegState = new FFmpegState(
saveReports,
DecoderHardwareAccelerationMode: HardwareAccelerationMode.None,
EncoderHardwareAccelerationMode: hwAccel,
vaapiDriver,
vaapiDevice,
playbackSettings.StreamSeek,
Finish: Option<TimeSpan>.None,
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
"ErsatzTV",
channel.Name,
MetadataAudioLanguage: Option<string>.None,
MetadataSubtitleLanguage: Option<string>.None,
MetadataSubtitleTitle: Option<string>.None,
OutputFormat: OutputFormatKind.Hls,
hlsPlaylistPath,
hlsSegmentTemplate,
PtsOffset: 0,
playbackSettings.ThreadCount,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
hwAccel,
videoInputFile,
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
vaapiDriver,
vaapiDevice,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
}
public async Task<Command> WrapSegmenter(
string ffmpegPath,
bool saveReports,
@ -589,6 +735,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -589,6 +735,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
None,
None,
concatInputFile,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
@ -627,6 +774,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -627,6 +774,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
None,
None,
Option<ConcatInputFile>.None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,

38
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -63,12 +63,12 @@ public static class FFmpegPlaybackSettingsCalculator @@ -63,12 +63,12 @@ public static class FFmpegPlaybackSettingsCalculator
RealtimeOutput = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime,
StreamingMode.HttpLiveStreamingSegmenterV2 => hlsRealtime,
_ => true
}
},
ThreadCount = ffmpegProfile.ThreadCount
};
result.ThreadCount = ffmpegProfile.ThreadCount;
if (now != start || inPoint != TimeSpan.Zero)
{
result.StreamSeek = now - start + inPoint;
@ -83,6 +83,7 @@ public static class FFmpegPlaybackSettingsCalculator @@ -83,6 +83,7 @@ public static class FFmpegPlaybackSettingsCalculator
break;
case StreamingMode.TransportStreamHybrid:
case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.HttpLiveStreamingSegmenterV2:
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
@ -169,6 +170,36 @@ public static class FFmpegPlaybackSettingsCalculator @@ -169,6 +170,36 @@ public static class FFmpegPlaybackSettingsCalculator
return result;
}
public static FFmpegPlaybackSettings CalculateConcatSegmenterSettings(
FFmpegProfile ffmpegProfile,
Option<int> targetFramerate) =>
new()
{
FormatFlags = CommonFormatFlags,
RealtimeOutput = false,
ThreadCount = ffmpegProfile.ThreadCount,
HardwareAcceleration = ffmpegProfile.HardwareAcceleration,
FrameRate = targetFramerate,
VideoTrackTimeScale = 90000,
VideoFormat = ffmpegProfile.VideoFormat,
VideoBitrate = ffmpegProfile.VideoBitrate,
VideoBufferSize = ffmpegProfile.VideoBufferSize,
VideoDecoder = null,
PixelFormat = ffmpegProfile.BitDepth switch
{
FFmpegProfileBitDepth.TenBit when ffmpegProfile.VideoFormat != FFmpegProfileVideoFormat.Mpeg2Video
=> new PixelFormatYuv420P10Le(),
_ => new PixelFormatYuv420P()
},
AudioFormat = ffmpegProfile.AudioFormat,
AudioBitrate = ffmpegProfile.AudioBitrate,
AudioBufferSize = ffmpegProfile.AudioBufferSize,
AudioChannels = ffmpegProfile.AudioChannels,
AudioSampleRate = ffmpegProfile.AudioSampleRate,
NormalizeLoudnessMode = ffmpegProfile.NormalizeLoudnessMode,
Deinterlace = false
};
public static FFmpegPlaybackSettings CalculateErrorSettings(
StreamingMode streamingMode,
FFmpegProfile ffmpegProfile,
@ -188,6 +219,7 @@ public static class FFmpegPlaybackSettingsCalculator @@ -188,6 +219,7 @@ public static class FFmpegPlaybackSettingsCalculator
RealtimeOutput = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime,
StreamingMode.HttpLiveStreamingSegmenterV2 => hlsRealtime,
_ => true
},
VideoTrackTimeScale = 90000,

6
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -186,15 +186,15 @@ internal class FFmpegProcessBuilder @@ -186,15 +186,15 @@ internal class FFmpegProcessBuilder
audioLabel = filter.AudioLabel;
});
_arguments.Add("-map");
_arguments.Add(videoLabel);
foreach (string _ in audioPath)
{
_arguments.Add("-map");
_arguments.Add(audioLabel);
}
_arguments.Add("-map");
_arguments.Add(videoLabel);
return this;
}

7
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -53,6 +53,13 @@ public interface IFFmpegProcessService @@ -53,6 +53,13 @@ public interface IFFmpegProcessService
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Task<Command> ConcatSegmenterChannel(
string ffmpegPath,
bool saveReports,
Channel channel,
string scheme,
string host);
Task<Command> WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);

2
ErsatzTV.Core/Interfaces/FFmpeg/IHlsSessionWorker.cs

@ -9,4 +9,6 @@ public interface IHlsSessionWorker : IDisposable @@ -9,4 +9,6 @@ public interface IHlsSessionWorker : IDisposable
Task<Option<TrimPlaylistResult>> TrimPlaylist(DateTimeOffset filterBefore, CancellationToken cancellationToken);
void PlayoutUpdated();
HlsSessionModel GetModel();
Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken);
Task WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken);
}

15
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -97,6 +97,7 @@ public class PipelineBuilderBaseTests @@ -97,6 +97,7 @@ public class PipelineBuilderBaseTests
audioInputFile,
None,
None,
None,
"",
"",
_logger);
@ -107,7 +108,7 @@ public class PipelineBuilderBaseTests @@ -107,7 +108,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
command.Should().Be(
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1:first_pts=0[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
@ -186,6 +187,7 @@ public class PipelineBuilderBaseTests @@ -186,6 +187,7 @@ public class PipelineBuilderBaseTests
audioInputFile,
None,
None,
None,
"",
"",
_logger);
@ -196,7 +198,7 @@ public class PipelineBuilderBaseTests @@ -196,7 +198,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
command.Should().Be(
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.0 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1:first_pts=0[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
@ -212,6 +214,7 @@ public class PipelineBuilderBaseTests @@ -212,6 +214,7 @@ public class PipelineBuilderBaseTests
None,
None,
None,
None,
"",
"",
_logger);
@ -240,6 +243,7 @@ public class PipelineBuilderBaseTests @@ -240,6 +243,7 @@ public class PipelineBuilderBaseTests
None,
None,
None,
None,
"",
"",
_logger);
@ -329,6 +333,7 @@ public class PipelineBuilderBaseTests @@ -329,6 +333,7 @@ public class PipelineBuilderBaseTests
audioInputFile,
None,
None,
None,
"",
"",
_logger);
@ -343,7 +348,7 @@ public class PipelineBuilderBaseTests @@ -343,7 +348,7 @@ public class PipelineBuilderBaseTests
// 0.4.0 reference: "-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -ss 00:14:33.6195516 -i /tmp/whatever.mkv -map 0:0 -map 0:a -c:v copy -flags cgop -sc_threshold 0 -c:a copy -movflags +faststart -muxdelay 0 -muxpreload 0 -metadata service_provider="ErsatzTV" -metadata service_name="ErsatzTV" -t 00:06:39.6934484 -f mpegts -mpegts_flags +initial_discontinuity pipe:1"
command.Should().Be(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
}
[Test]
@ -412,6 +417,7 @@ public class PipelineBuilderBaseTests @@ -412,6 +417,7 @@ public class PipelineBuilderBaseTests
audioInputFile,
None,
None,
None,
"",
"",
_logger);
@ -424,7 +430,7 @@ public class PipelineBuilderBaseTests @@ -424,7 +430,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
command.Should().Be(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:a -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
}
[Test]
@ -456,6 +462,7 @@ public class PipelineBuilderBaseTests @@ -456,6 +462,7 @@ public class PipelineBuilderBaseTests
Option<AudioInputFile>.None,
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
None,
"",
"",
_logger);

12
ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Logging;
@ -6,8 +7,14 @@ namespace ErsatzTV.FFmpeg.Encoder; @@ -6,8 +7,14 @@ namespace ErsatzTV.FFmpeg.Encoder;
public static class AvailableEncoders
{
public static Option<IEncoder> ForAudioFormat(AudioState desiredState, ILogger logger) =>
desiredState.AudioFormat.Match(
public static Option<IEncoder> ForAudioFormat(FFmpegState ffmpegState, AudioState desiredState, ILogger logger)
{
if (ffmpegState.OutputFormat is OutputFormatKind.Nut)
{
return new EncoderPcmS16Le();
}
return desiredState.AudioFormat.Match(
audioFormat =>
audioFormat switch
{
@ -17,6 +24,7 @@ public static class AvailableEncoders @@ -17,6 +24,7 @@ public static class AvailableEncoders
_ => LogUnknownEncoder(audioFormat, logger)
},
() => LogUnknownEncoder(string.Empty, logger));
}
private static Option<IEncoder> LogUnknownEncoder(
string audioFormat,

8
ErsatzTV.FFmpeg/Encoder/EncoderPcmS16Le.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Encoder;
public class EncoderPcmS16Le : EncoderBase
{
public override string Name => "pcm_s16le";
public override StreamKind Kind => StreamKind.Audio;
}

13
ErsatzTV.FFmpeg/Encoder/EncoderRawVideo.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Encoder;
public class EncoderRawVideo : EncoderBase
{
public override string Name => "rawvideo";
public override StreamKind Kind => StreamKind.Video;
public override FrameState NextState(FrameState currentState) =>
currentState with { VideoFormat = VideoFormat.Raw };
}

28
ErsatzTV.FFmpeg/FFmpegState.cs

@ -46,4 +46,32 @@ public record FFmpegState( @@ -46,4 +46,32 @@ public record FFmpegState(
0,
Option<int>.None,
Option<int>.None);
public static FFmpegState ConcatSegmenter(
bool saveReport,
string channelName,
Option<string> vaapiDriver,
Option<string> vaapiDevice,
Option<string> hlsPlaylistPath,
Option<string> hlsSegmentTemplate) =>
new(
saveReport,
HardwareAccelerationMode.None,
HardwareAccelerationMode.Vaapi,
vaapiDriver,
vaapiDevice,
Option<TimeSpan>.None,
Option<TimeSpan>.None,
true, // do not map metadata
"ErsatzTV",
channelName,
Option<string>.None,
Option<string>.None,
Option<string>.None,
OutputFormatKind.Hls,
hlsPlaylistPath,
hlsSegmentTemplate,
0,
Option<int>.None,
Option<int>.None);
}

12
ErsatzTV.FFmpeg/Filter/AudioFirstPtsFilter.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
namespace ErsatzTV.FFmpeg.Filter;
public class AudioFirstPtsFilter : BaseFilter
{
private readonly int _pts;
public AudioFirstPtsFilter(int pts) => _pts = pts;
public override string Filter => $"aresample=async=1:first_pts={_pts}";
public override FrameState NextState(FrameState currentState) => currentState;
}

16
ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs

@ -1,21 +1,9 @@ @@ -1,21 +1,9 @@
using System.Globalization;
namespace ErsatzTV.FFmpeg.Filter;
namespace ErsatzTV.FFmpeg.Filter;
public class AudioPadFilter : BaseFilter
{
private readonly TimeSpan _wholeDuration;
public AudioPadFilter(TimeSpan wholeDuration) => _wholeDuration = wholeDuration;
public override string Filter
{
get
{
var durationString = _wholeDuration.TotalMilliseconds.ToString(NumberFormatInfo.InvariantInfo);
return $"apad=whole_dur={durationString}ms";
}
}
public override string Filter => "apad";
public override FrameState NextState(FrameState currentState) => currentState;
}

2
ErsatzTV.FFmpeg/Filter/ComplexFilter.cs

@ -230,7 +230,7 @@ public class ComplexFilter : IPipelineStep @@ -230,7 +230,7 @@ public class ComplexFilter : IPipelineStep
result.AddRange(new[] { "-filter_complex", filterComplex });
}
result.AddRange(new[] { "-map", audioLabel, "-map", videoLabel });
result.AddRange(new[] { "-map", videoLabel, "-map", audioLabel });
foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(
s => s.Method == SubtitleMethod.Copy ||

8
ErsatzTV.FFmpeg/Filter/RealtimeFilter.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Filter;
public class RealtimeFilter : BaseFilter
{
public override FrameState NextState(FrameState currentState) => currentState with { Realtime = true };
public override string Filter => "realtime";
}

1
ErsatzTV.FFmpeg/Format/VideoFormat.cs

@ -13,6 +13,7 @@ public static class VideoFormat @@ -13,6 +13,7 @@ public static class VideoFormat
public const string Vp9 = "vp9";
public const string Av1 = "av1";
public const string MpegTs = "mpegts";
public const string Raw = "raw";
public const string Copy = "copy";
public const string GeneratedImage = "generated-image";

38
ErsatzTV.FFmpeg/OutputFormat/OutputFormatConcatHls.cs

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.OutputFormat;
public class OutputFormatConcatHls : IPipelineStep
{
private readonly string _playlistPath;
private readonly string _segmentTemplate;
public OutputFormatConcatHls(string segmentTemplate, string playlistPath)
{
_segmentTemplate = segmentTemplate;
_playlistPath = playlistPath;
}
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public string[] InputOptions(InputFile inputFile) => Array.Empty<string>();
public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions =>
[
//"-g", $"{gop}",
//"-keyint_min", $"{FRAME_RATE * OutputFormatHls.SegmentSeconds}",
"-force_key_frames", $"expr:gte(t,n_forced*{OutputFormatHls.SegmentSeconds}/2)",
"-f", "hls",
//"-hls_segment_type", "fmp4",
//"-hls_init_time", "2",
"-hls_time", $"{OutputFormatHls.SegmentSeconds}",
"-hls_list_size", "25", // burst of 45 means ~12 segments, so allow that plus a handful
"-segment_list_flags", "+live",
"-hls_segment_filename", _segmentTemplate,
"-hls_flags", "delete_segments+program_date_time+omit_endlist+discont_start+independent_segments",
_playlistPath
];
public FrameState NextState(FrameState currentState) => currentState;
}

11
ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs

@ -4,6 +4,8 @@ namespace ErsatzTV.FFmpeg.OutputFormat; @@ -4,6 +4,8 @@ namespace ErsatzTV.FFmpeg.OutputFormat;
public class OutputFormatHls : IPipelineStep
{
public const int SegmentSeconds = 4;
private readonly FrameState _desiredState;
private readonly Option<string> _mediaFrameRate;
private readonly bool _oneSecondGop;
@ -36,18 +38,17 @@ public class OutputFormatHls : IPipelineStep @@ -36,18 +38,17 @@ public class OutputFormatHls : IPipelineStep
{
get
{
const int SEGMENT_SECONDS = 4;
int frameRate = _desiredState.FrameRate.IfNone(GetFrameRateFromMedia);
int gop = _oneSecondGop ? frameRate : frameRate * SEGMENT_SECONDS;
int gop = _oneSecondGop ? frameRate : frameRate * SegmentSeconds;
List<string> result =
[
"-g", $"{gop}",
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
"-keyint_min", $"{frameRate * SegmentSeconds}",
"-force_key_frames", $"expr:gte(t,n_forced*{SegmentSeconds})",
"-f", "hls",
"-hls_time", $"{SEGMENT_SECONDS}",
"-hls_time", $"{SegmentSeconds}",
"-hls_list_size", "0",
"-segment_list_flags", "+live",
"-hls_segment_filename",

4
ErsatzTV.FFmpeg/OutputFormat/OutputFormatKind.cs

@ -6,5 +6,7 @@ public enum OutputFormatKind @@ -6,5 +6,7 @@ public enum OutputFormatKind
Mkv,
MpegTs,
Mp4,
Hls
Hls,
Nut
}

6
ErsatzTV.FFmpeg/OutputOption/NoBFramesOutputOption.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.FFmpeg.OutputOption;
public class NoBFramesOutputOption : OutputOption
{
public override string[] OutputOptions => ["-bf", "0"];
}

6
ErsatzTV.FFmpeg/OutputOption/ShortestOutputOption.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.FFmpeg.OutputOption;
public class ShortestOutputOption : OutputOption
{
public override string[] OutputOptions => ["-shortest"];
}

4
ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs

@ -22,6 +22,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder @@ -22,6 +22,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -31,6 +32,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder @@ -31,6 +32,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
logger)
@ -80,7 +82,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder @@ -80,7 +82,7 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder
(HardwareAccelerationMode.Amf, VideoFormat.H264) =>
new EncoderH264Amf(),
_ => GetSoftwareEncoder(currentState, desiredState)
_ => GetSoftwareEncoder(ffmpegState, currentState, desiredState)
};
protected override List<IPipelineFilterStep> SetPixelFormat(

1
ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs

@ -4,6 +4,7 @@ public interface IPipelineBuilder @@ -4,6 +4,7 @@ public interface IPipelineBuilder
{
FFmpegPipeline Resize(string outputFile, FrameSize scaledSize);
FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState);
FFmpegPipeline ConcatSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState);
FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState);
FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState);
}

1
ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs

@ -8,6 +8,7 @@ public interface IPipelineBuilderFactory @@ -8,6 +8,7 @@ public interface IPipelineBuilderFactory
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<string> vaapiDriver,
Option<string> vaapiDevice,
string reportsFolder,

6
ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs

@ -28,6 +28,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -28,6 +28,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -37,6 +38,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -37,6 +38,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
logger)
@ -161,7 +163,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -161,7 +163,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
currentState = SetScale(videoInputFile, videoStream, context, ffmpegState, desiredState, currentState);
currentState = SetPad(videoInputFile, videoStream, desiredState, currentState);
currentState = SetCrop(videoInputFile, desiredState, currentState);
SetStillImageLoop(videoInputFile, videoStream);
SetStillImageLoop(videoInputFile, videoStream, desiredState, pipelineSteps);
if (currentState.BitDepth == 8 && context.HasSubtitleOverlay || context.HasWatermark)
{
@ -243,7 +245,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -243,7 +245,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc) => new EncoderHevcNvenc(_hardwareCapabilities),
(HardwareAccelerationMode.Nvenc, VideoFormat.H264) => new EncoderH264Nvenc(),
(_, _) => GetSoftwareEncoder(currentState, desiredState)
(_, _) => GetSoftwareEncoder(ffmpegState, currentState, desiredState)
};
foreach (IEncoder encoder in maybeEncoder)

170
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -2,10 +2,12 @@ using System.Diagnostics; @@ -2,10 +2,12 @@ using System.Diagnostics;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Decoder;
using ErsatzTV.FFmpeg.Encoder;
using ErsatzTV.FFmpeg.Encoder.Vaapi;
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Filter;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.GlobalOption;
using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
using ErsatzTV.FFmpeg.InputOption;
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.OutputOption;
@ -24,6 +26,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -24,6 +26,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
private readonly ILogger _logger;
private readonly string _reportsFolder;
private readonly Option<SubtitleInputFile> _subtitleInputFile;
private readonly Option<ConcatInputFile> _concatInputFile;
private readonly Option<VideoInputFile> _videoInputFile;
private readonly Option<WatermarkInputFile> _watermarkInputFile;
@ -34,6 +37,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -34,6 +37,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
string reportsFolder,
string fontsFolder,
ILogger logger)
@ -44,6 +48,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -44,6 +48,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
_audioInputFile = audioInputFile;
_watermarkInputFile = watermarkInputFile;
_subtitleInputFile = subtitleInputFile;
_concatInputFile = concatInputFile;
_reportsFolder = reportsFolder;
_fontsFolder = fontsFolder;
_logger = logger;
@ -115,6 +120,71 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -115,6 +120,71 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
return new FFmpegPipeline(pipelineSteps, false);
}
public FFmpegPipeline ConcatSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState)
{
var pipelineSteps = new List<IPipelineStep>
{
new NoStandardInputOption(),
new HideBannerOption(),
new NoStatsOption(),
new LoglevelErrorOption(),
new StandardFormatFlags(),
new NoDemuxDecodeDelayOutputOption(),
new FastStartOutputOption(),
new ClosedGopOutputOption(),
new NoBFramesOutputOption()
};
concatInputFile.AddOption(new ConcatInputFormat());
concatInputFile.AddOption(new InfiniteLoopInputOption(HardwareAccelerationMode.None));
foreach (int threadCount in ffmpegState.ThreadCount)
{
pipelineSteps.Insert(0, new ThreadCountOption(threadCount));
}
pipelineSteps.Add(new NoSceneDetectOutputOption(0));
foreach (string vaapiDevice in ffmpegState.VaapiDevice)
{
pipelineSteps.Add(new VaapiHardwareAccelerationOption(vaapiDevice, FFmpegCapability.Software));
foreach (string driverName in ffmpegState.VaapiDriver)
{
pipelineSteps.Add(new LibvaDriverNameVariable(driverName));
}
}
pipelineSteps.Add(new EncoderH264Vaapi(RateControlMode.VBR));
pipelineSteps.Add(new EncoderAac());
//pipelineSteps.Add(new EncoderCopyAll());
if (ffmpegState.DoNotMapMetadata)
{
pipelineSteps.Add(new DoNotMapMetadataOutputOption());
}
pipelineSteps.AddRange(
ffmpegState.MetadataServiceProvider.Map(sp => new MetadataServiceProviderOutputOption(sp)));
pipelineSteps.AddRange(ffmpegState.MetadataServiceName.Map(sn => new MetadataServiceNameOutputOption(sn)));
foreach (string segmentTemplate in ffmpegState.HlsSegmentTemplate)
{
foreach (string playlistPath in ffmpegState.HlsPlaylistPath)
{
pipelineSteps.Add(new OutputFormatConcatHls(segmentTemplate, playlistPath));
}
}
if (ffmpegState.SaveReport)
{
pipelineSteps.Add(new FFReportVariable(_reportsFolder, concatInputFile));
}
return new FFmpegPipeline(pipelineSteps, false);
}
public FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState)
{
@ -158,9 +228,20 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -158,9 +228,20 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
new StandardFormatFlags(),
new NoDemuxDecodeDelayOutputOption(),
outputOption,
new ClosedGopOutputOption()
new ClosedGopOutputOption(),
};
if (desiredState.VideoFormat != VideoFormat.Copy)
{
pipelineSteps.Add(new NoBFramesOutputOption());
}
foreach (ConcatInputFile concatInputFile in _concatInputFile)
{
concatInputFile.AddOption(new ConcatInputFormat());
concatInputFile.AddOption(new InfiniteLoopInputOption(HardwareAccelerationMode.None));
}
Debug.Assert(_videoInputFile.IsSome, "Pipeline builder requires exactly one video input file");
VideoInputFile videoInputFile = _videoInputFile.Head();
@ -201,7 +282,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -201,7 +282,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
{
foreach (AudioInputFile audioInputFile in _audioInputFile)
{
BuildAudioPipeline(audioInputFile, pipelineSteps);
BuildAudioPipeline(ffmpegState, audioInputFile, pipelineSteps);
}
}
@ -210,7 +291,21 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -210,7 +291,21 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
SetMetadataServiceName(ffmpegState, pipelineSteps);
SetMetadataAudioLanguage(ffmpegState, pipelineSteps);
SetMetadataSubtitle(ffmpegState, pipelineSteps);
SetOutputFormat(ffmpegState, desiredState, pipelineSteps, videoStream);
if (_concatInputFile.IsSome)
{
foreach (string segmentTemplate in ffmpegState.HlsSegmentTemplate)
{
foreach (string playlistPath in ffmpegState.HlsPlaylistPath)
{
pipelineSteps.Add(new OutputFormatConcatHls(segmentTemplate, playlistPath));
}
}
}
else
{
SetOutputFormat(ffmpegState, desiredState, pipelineSteps, videoStream);
}
var complexFilter = new ComplexFilter(
_videoInputFile,
@ -260,6 +355,12 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -260,6 +355,12 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
pipelineSteps.Add(new OutputFormatMpegTs());
pipelineSteps.Add(new PipeProtocol());
break;
case OutputFormatKind.Nut:
// yes, not really "nut" - but nut is currently used to indicate a transcoding
// source that feeds into a concat segmenter
pipelineSteps.Add(new OutputFormatMkv());
pipelineSteps.Add(new PipeProtocol());
break;
case OutputFormatKind.Mp4:
pipelineSteps.Add(new OutputFormatMp4());
pipelineSteps.Add(new PipeProtocol());
@ -329,27 +430,37 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -329,27 +430,37 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
}
}
private void BuildAudioPipeline(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
private void BuildAudioPipeline(FFmpegState ffmpegState, AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
{
// always need to specify audio codec so ffmpeg doesn't default to a codec we don't want
foreach (IEncoder step in AvailableEncoders.ForAudioFormat(audioInputFile.DesiredState, _logger))
foreach (IEncoder step in AvailableEncoders.ForAudioFormat(ffmpegState, audioInputFile.DesiredState, _logger))
{
pipelineSteps.Add(step);
}
SetAudioChannels(audioInputFile, pipelineSteps);
SetAudioBitrate(audioInputFile, pipelineSteps);
SetAudioBufferSize(audioInputFile, pipelineSteps);
SetAudioSampleRate(audioInputFile, pipelineSteps);
if (ffmpegState.OutputFormat is not OutputFormatKind.Nut)
{
SetAudioBitrate(audioInputFile, pipelineSteps);
SetAudioBufferSize(audioInputFile, pipelineSteps);
SetAudioSampleRate(audioInputFile, pipelineSteps);
}
SetAudioLoudness(audioInputFile);
SetAudioPad(audioInputFile);
SetAudioPad(audioInputFile, pipelineSteps);
}
private void SetAudioPad(AudioInputFile audioInputFile)
private void SetAudioPad(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
{
foreach (TimeSpan desiredDuration in audioInputFile.DesiredState.AudioDuration)
if (pipelineSteps.All(ps => ps is not EncoderCopyAudio))
{
_audioInputFile.Iter(f => f.FilterSteps.Add(new AudioFirstPtsFilter(0)));
}
foreach (TimeSpan _ in audioInputFile.DesiredState.AudioDuration)
{
_audioInputFile.Iter(f => f.FilterSteps.Add(new AudioPadFilter(desiredDuration)));
_audioInputFile.Iter(f => f.FilterSteps.Add(new AudioPadFilter()));
}
}
@ -446,8 +557,12 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -446,8 +557,12 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
SetInfiniteLoop(videoInputFile, videoStream, ffmpegState, desiredState);
SetFrameRateOutput(desiredState, pipelineSteps);
SetVideoTrackTimescaleOutput(desiredState, pipelineSteps);
SetVideoBitrateOutput(desiredState, pipelineSteps);
SetVideoBufferSizeOutput(desiredState, pipelineSteps);
if (ffmpegState.OutputFormat is not OutputFormatKind.Nut)
{
SetVideoBitrateOutput(desiredState, pipelineSteps);
SetVideoBufferSizeOutput(desiredState, pipelineSteps);
}
FilterChain filterChain = SetVideoFilters(
videoInputFile,
@ -486,8 +601,14 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -486,8 +601,14 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
return maybeDecoder;
}
protected Option<IEncoder> GetSoftwareEncoder(FrameState currentState, FrameState desiredState) =>
desiredState.VideoFormat switch
protected Option<IEncoder> GetSoftwareEncoder(FFmpegState ffmpegState, FrameState currentState, FrameState desiredState)
{
if (ffmpegState.OutputFormat is OutputFormatKind.Nut)
{
return new EncoderRawVideo();
}
return desiredState.VideoFormat switch
{
VideoFormat.Hevc => new EncoderLibx265(
currentState with { FrameDataLocation = FrameDataLocation.Software }),
@ -499,6 +620,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -499,6 +620,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
_ => LogUnknownEncoder(HardwareAccelerationMode.None, desiredState.VideoFormat)
};
}
protected abstract FilterChain SetVideoFilters(
VideoInputFile videoInputFile,
@ -625,7 +747,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -625,7 +747,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
int initialBurst;
if (!desiredState.Realtime)
{
initialBurst = 180;
initialBurst = 45;
}
else
{
@ -644,11 +766,21 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -644,11 +766,21 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
videoInputFile.AddOption(new ReadrateInputOption(_ffmpegCapabilities, initialBurst, _logger));
}
protected static void SetStillImageLoop(VideoInputFile videoInputFile, VideoStream videoStream)
protected static void SetStillImageLoop(
VideoInputFile videoInputFile,
VideoStream videoStream,
FrameState desiredState,
ICollection<IPipelineStep> pipelineSteps)
{
if (videoStream.StillImage)
{
videoInputFile.FilterSteps.Add(new LoopFilter());
if (desiredState.Realtime)
{
videoInputFile.FilterSteps.Add(new RealtimeFilter());
}
//pipelineSteps.Add(new ShortestOutputOption());
}
}
@ -703,7 +835,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -703,7 +835,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
{
if (ffmpegState.SaveReport)
{
pipelineSteps.Add(new FFReportVariable(_reportsFolder, None));
pipelineSteps.Add(new FFReportVariable(_reportsFolder, _concatInputFile));
}
}

7
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs

@ -22,6 +22,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -22,6 +22,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
Option<string> vaapiDriver,
Option<string> vaapiDevice,
string reportsFolder,
@ -47,6 +48,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -47,6 +48,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
_logger),
@ -58,6 +60,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -58,6 +60,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
_logger),
@ -69,6 +72,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -69,6 +72,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
_logger),
@ -81,6 +85,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -81,6 +85,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
_logger),
@ -92,6 +97,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -92,6 +97,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
_logger),
@ -102,6 +108,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -102,6 +108,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
_logger)

6
ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs

@ -27,6 +27,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -27,6 +27,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -36,6 +37,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -36,6 +37,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
logger)
@ -166,7 +168,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -166,7 +168,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
currentState = SetPad(videoInputFile, videoStream, desiredState, currentState);
// _logger.LogDebug("After pad: {PixelFormat}", currentState.PixelFormat);
currentState = SetCrop(videoInputFile, desiredState, currentState);
SetStillImageLoop(videoInputFile, videoStream);
SetStillImageLoop(videoInputFile, videoStream, desiredState, pipelineSteps);
// need to download for any sort of overlay
if (currentState.FrameDataLocation == FrameDataLocation.Hardware &&
@ -206,7 +208,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder @@ -206,7 +208,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
(HardwareAccelerationMode.Qsv, VideoFormat.H264) => new EncoderH264Qsv(),
(HardwareAccelerationMode.Qsv, VideoFormat.Mpeg2Video) => new EncoderMpeg2Qsv(),
(_, _) => GetSoftwareEncoder(currentState, desiredState)
(_, _) => GetSoftwareEncoder(ffmpegState, currentState, desiredState)
};
foreach (IEncoder encoder in maybeEncoder)

6
ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs

@ -21,6 +21,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -21,6 +21,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -30,6 +31,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -30,6 +31,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
logger) =>
@ -67,7 +69,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -67,7 +69,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
FFmpegState ffmpegState,
FrameState currentState,
FrameState desiredState) =>
GetSoftwareEncoder(currentState, desiredState);
GetSoftwareEncoder(ffmpegState, currentState, desiredState);
protected override FilterChain SetVideoFilters(
VideoInputFile videoInputFile,
@ -105,7 +107,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase @@ -105,7 +107,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
currentState = SetScale(videoInputFile, videoStream, desiredState, currentState);
currentState = SetPad(videoInputFile, videoStream, desiredState, currentState);
currentState = SetCrop(videoInputFile, desiredState, currentState);
SetStillImageLoop(videoInputFile, videoStream);
SetStillImageLoop(videoInputFile, videoStream, desiredState, pipelineSteps);
SetSubtitle(
videoInputFile,
subtitleInputFile,

13
ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs

@ -8,6 +8,7 @@ using ErsatzTV.FFmpeg.Filter.Vaapi; @@ -8,6 +8,7 @@ using ErsatzTV.FFmpeg.Filter.Vaapi;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
using ErsatzTV.FFmpeg.InputOption;
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.OutputOption;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Logging;
@ -27,6 +28,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -27,6 +28,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -36,6 +38,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -36,6 +38,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
logger)
@ -66,6 +69,12 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -66,6 +69,12 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
desiredState.VideoProfile,
desiredState.PixelFormat);
// use software encoding (rawvideo) when piping to parent hls segmenter
if (ffmpegState.OutputFormat is OutputFormatKind.Nut)
{
encodeCapability = FFmpegCapability.Software;
}
foreach (string vaapiDevice in ffmpegState.VaapiDevice)
{
pipelineSteps.Add(new VaapiHardwareAccelerationOption(vaapiDevice, decodeCapability));
@ -170,7 +179,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -170,7 +179,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
currentState = SetCrop(videoInputFile, desiredState, currentState);
SetStillImageLoop(videoInputFile, videoStream);
SetStillImageLoop(videoInputFile, videoStream, desiredState, pipelineSteps);
// need to upload for hardware overlay
bool forceSoftwareOverlay = context is { HasSubtitleOverlay: true, HasWatermark: true }
@ -225,7 +234,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder @@ -225,7 +234,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
(HardwareAccelerationMode.Vaapi, VideoFormat.H264) => new EncoderH264Vaapi(rateControlMode),
(HardwareAccelerationMode.Vaapi, VideoFormat.Mpeg2Video) => new EncoderMpeg2Vaapi(rateControlMode),
(_, _) => GetSoftwareEncoder(currentState, desiredState)
(_, _) => GetSoftwareEncoder(ffmpegState, currentState, desiredState)
};
foreach (IEncoder encoder in maybeEncoder)

4
ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs

@ -23,6 +23,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder @@ -23,6 +23,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
Option<ConcatInputFile> concatInputFile,
string reportsFolder,
string fontsFolder,
ILogger logger) : base(
@ -32,6 +33,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder @@ -32,6 +33,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
logger)
@ -103,7 +105,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder @@ -103,7 +105,7 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder
(HardwareAccelerationMode.VideoToolbox, VideoFormat.H264) =>
new EncoderH264VideoToolbox(),
_ => GetSoftwareEncoder(currentState, desiredState)
_ => GetSoftwareEncoder(ffmpegState, currentState, desiredState)
};
protected override List<IPipelineFilterStep> SetPixelFormat(

164
ErsatzTV/Controllers/InternalController.cs

@ -7,6 +7,7 @@ using ErsatzTV.Application.Streaming; @@ -7,6 +7,7 @@ using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Subtitles.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Extensions;
using Flurl;
using MediatR;
@ -19,78 +20,39 @@ namespace ErsatzTV.Controllers; @@ -19,78 +20,39 @@ namespace ErsatzTV.Controllers;
public class InternalController : ControllerBase
{
private readonly ILogger<InternalController> _logger;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IMediator _mediator;
public InternalController(IMediator mediator, ILogger<InternalController> logger)
public InternalController(
IFFmpegSegmenterService ffmpegSegmenterService,
IMediator mediator,
ILogger<InternalController> logger)
{
_ffmpegSegmenterService = ffmpegSegmenterService;
_mediator = mediator;
_logger = logger;
}
[HttpGet("ffmpeg/concat/{channelNumber}")]
public Task<IActionResult> GetConcatPlaylist(string channelNumber) =>
_mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
public Task<IActionResult> GetConcatPlaylist(string channelNumber, [FromQuery] string mode = "ts-legacy") =>
_mediator.Send(
new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber, mode))
.ToActionResult();
[HttpGet("ffmpeg/stream/{channelNumber}")]
public Task<IActionResult> GetStream(
public async Task<IActionResult> GetStream(
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(
new GetPlayoutItemProcessByChannelNumber(
channelNumber,
mode,
DateTimeOffset.Now,
false,
true,
0,
Option<int>.None))
.Map(
result =>
result.Match<IActionResult>(
processModel =>
{
Command command = processModel.Process;
_logger.LogDebug("ffmpeg arguments {FFmpegArguments}", command.Arguments);
var process = new FFmpegProcess
{
StartInfo = new ProcessStartInfo
{
FileName = command.TargetFilePath,
Arguments = command.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true
}
};
HttpContext.Response.RegisterForDispose(process);
foreach ((string key, string value) in command.EnvironmentVariables)
{
process.StartInfo.Environment[key] = value;
}
var contentType = "video/mp2t";
if (mode.Equals("hls-direct", StringComparison.OrdinalIgnoreCase))
{
contentType = "video/mp4";
}
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, contentType);
},
error =>
{
_logger.LogError(
"Failed to create stream for channel {ChannelNumber}: {Error}",
channelNumber,
error.Value);
return BadRequest(error.Value);
}
));
string mode = "mixed")
{
switch (mode)
{
case "segmenter-v2":
return await GetSegmenterV2Stream(channelNumber);
default:
return await GetTsLegacyStream(channelNumber, mode);
}
}
[HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")]
public async Task<IActionResult> GetPlexMedia(
@ -195,4 +157,88 @@ public class InternalController : ControllerBase @@ -195,4 +157,88 @@ public class InternalController : ControllerBase
return new PhysicalFileResult(r, mimeType);
});
}
private async Task<IActionResult> GetSegmenterV2Stream(string channelNumber)
{
if (_ffmpegSegmenterService.TryGetWorker(channelNumber, out IHlsSessionWorker worker) &&
worker is HlsSessionWorkerV2 v2)
{
Either<BaseError, PlayoutItemProcessModel> result = await v2.GetNextPlayoutItemProcess();
return GetProcessResponse(result, channelNumber, "segmenter-v2");
}
_logger.LogWarning("Unable to locate session worker for channel {Channel}", channelNumber);
return new NotFoundResult();
}
private async Task<IActionResult> GetTsLegacyStream(string channelNumber, string mode)
{
var request = new GetPlayoutItemProcessByChannelNumber(
channelNumber,
mode,
DateTimeOffset.Now,
false,
true,
0,
Option<int>.None);
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
return GetProcessResponse(result, channelNumber, mode);
}
private IActionResult GetProcessResponse(
Either<BaseError, PlayoutItemProcessModel> result,
string channelNumber,
string mode)
{
foreach (BaseError error in result.LeftToSeq())
{
_logger.LogError(
"Failed to create stream for channel {ChannelNumber}: {Error}",
channelNumber,
error.Value);
return BadRequest(error.Value);
}
foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
{
Command command = processModel.Process;
_logger.LogDebug("ffmpeg arguments {FFmpegArguments}", command.Arguments);
var process = new FFmpegProcess
{
StartInfo = new ProcessStartInfo
{
FileName = command.TargetFilePath,
Arguments = command.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true
}
};
HttpContext.Response.RegisterForDispose(process);
foreach ((string key, string value) in command.EnvironmentVariables)
{
process.StartInfo.Environment[key] = value;
}
var contentType = "video/mp2t";
if (mode.Equals("hls-direct", StringComparison.OrdinalIgnoreCase))
{
contentType = "video/mp4";
}
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, contentType);
}
// this will never happen
return new NotFoundResult();
}
}

25
ErsatzTV/Controllers/IptvController.cs

@ -136,7 +136,7 @@ public class IptvController : ControllerBase @@ -136,7 +136,7 @@ public class IptvController : ControllerBase
},
error => BadRequest(error.Value)));
}
[HttpGet("iptv/session/{channelNumber}/hls.m3u8")]
public async Task<IActionResult> GetLivePlaylist(string channelNumber, CancellationToken cancellationToken)
{
@ -182,6 +182,9 @@ public class IptvController : ControllerBase @@ -182,6 +182,9 @@ public class IptvController : ControllerBase
case StreamingMode.HttpLiveStreamingSegmenter:
mode = "segmenter";
break;
case StreamingMode.HttpLiveStreamingSegmenterV2:
mode = "segmenter-v2";
break;
default:
return Redirect($"~/iptv/channel/{channelNumber}.ts{AccessTokenQuery()}");
}
@ -191,9 +194,11 @@ public class IptvController : ControllerBase @@ -191,9 +194,11 @@ public class IptvController : ControllerBase
switch (mode)
{
case "segmenter":
string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber);
_logger.LogDebug("Maybe starting ffmpeg session for channel {Channel}", channelNumber);
Either<BaseError, Unit> result = await _mediator.Send(new StartFFmpegSession(channelNumber, false));
case "segmenter-v2":
string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber, mode);
_logger.LogDebug("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);
return result.Match<IActionResult>(
_ =>
{
@ -247,8 +252,16 @@ public class IptvController : ControllerBase @@ -247,8 +252,16 @@ public class IptvController : ControllerBase
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
}
private async Task<string> GetMultiVariantPlaylist(string channelNumber)
private async Task<string> GetMultiVariantPlaylist(string channelNumber, string mode)
{
string file = mode switch
{
// this serves the unmodified playlist from disk
"segmenter-v2" => "live.m3u8",
_ => "hls.m3u8"
};
Option<ResolutionViewModel> maybeResolution = await _mediator.Send(new GetChannelResolution(channelNumber));
string resolution = string.Empty;
foreach (ResolutionViewModel res in maybeResolution)
@ -259,7 +272,7 @@ public class IptvController : ControllerBase @@ -259,7 +272,7 @@ public class IptvController : ControllerBase
return $@"#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=10000000{resolution}
{Request.Scheme}://{Request.Host}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}";
{Request.Scheme}://{Request.Host}/iptv/session/{channelNumber}/{file}{AccessTokenQuery()}";
}
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])

1
ErsatzTV/Pages/ChannelEditor.razor

@ -35,6 +35,7 @@ @@ -35,6 +35,7 @@
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS (Legacy)</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenterV2)">HLS Segmenter V2</MudSelectItem>
</MudSelect>
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)">

1
ErsatzTV/Pages/Channels.razor

@ -247,6 +247,7 @@ @@ -247,6 +247,7 @@
{
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
StreamingMode.TransportStreamHybrid => "MPEG-TS",
_ => "MPEG-TS (Legacy)"
};

2
ErsatzTV/Startup.cs

@ -527,6 +527,8 @@ public class Startup @@ -527,6 +527,8 @@ public class Startup
.GetRequiredService<ChannelWriter<IFFmpegWorkerRequest>>();
writer.TryWrite(new TouchFFmpegSession(ctx.File.PhysicalPath));
}
// to serve m4s
// ServeUnknownFileTypes = true
});
app.UseRouting();

Loading…
Cancel
Save