Browse Source

properly track discontinuity sequences with fmp4 (#2548)

* properly track discontinuity sequences with fmp4

* update dependencies
pull/2549/head
Jason Dove 10 months ago committed by GitHub
parent
commit
d14ebf3522
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  2. 80
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  3. 3
      ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs
  4. 3
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs
  5. 21
      ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs
  6. 5
      ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs
  7. 170
      ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs
  8. 19
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  9. 3
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs
  10. 671
      ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs
  11. 3
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  12. 57
      ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs
  13. 4
      ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs
  14. 1
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  15. 4
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

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

@ -11,7 +11,6 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg.OutputFormat;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -123,7 +122,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
{ {
_ => new HlsSessionWorker( _ => new HlsSessionWorker(
_serviceScopeFactory, _serviceScopeFactory,
OutputFormatKind.HlsMp4,
_graphicsEngine, _graphicsEngine,
_client, _client,
_hlsPlaylistFilter, _hlsPlaylistFilter,

80
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -27,8 +27,8 @@ public class HlsSessionWorker : IHlsSessionWorker
private static int _workAheadCount; private static int _workAheadCount;
private readonly IClient _client; private readonly IClient _client;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache; private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = [];
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly OutputFormatKind _outputFormat;
private readonly IGraphicsEngine _graphicsEngine; private readonly IGraphicsEngine _graphicsEngine;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter; private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
@ -40,8 +40,8 @@ public class HlsSessionWorker : IHlsSessionWorker
private CancellationTokenSource _cancellationTokenSource; private CancellationTokenSource _cancellationTokenSource;
private string _channelNumber; private string _channelNumber;
private DateTimeOffset _channelStart; private DateTimeOffset _channelStart;
private int _discontinuitySequence;
private bool _disposedValue; private bool _disposedValue;
private bool _hasWrittenSegments;
private DateTimeOffset _lastAccess; private DateTimeOffset _lastAccess;
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue; private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
private IServiceScope _serviceScope; private IServiceScope _serviceScope;
@ -52,7 +52,6 @@ public class HlsSessionWorker : IHlsSessionWorker
public HlsSessionWorker( public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
OutputFormatKind outputFormat,
IGraphicsEngine graphicsEngine, IGraphicsEngine graphicsEngine,
IClient client, IClient client,
IHlsPlaylistFilter hlsPlaylistFilter, IHlsPlaylistFilter hlsPlaylistFilter,
@ -64,7 +63,6 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
_serviceScope = serviceScopeFactory.CreateScope(); _serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>(); _mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_outputFormat = outputFormat;
_graphicsEngine = graphicsEngine; _graphicsEngine = graphicsEngine;
_client = client; _client = client;
_hlsInitSegmentCache = hlsInitSegmentCache; _hlsInitSegmentCache = hlsInitSegmentCache;
@ -121,7 +119,8 @@ public class HlsSessionWorker : IHlsSessionWorker
await RefreshInits(); await RefreshInits();
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
_outputFormat, _discontinuityMap,
OutputFormatKind.HlsMp4,
PlaylistStart, PlaylistStart,
filterBefore, filterBefore,
_hlsInitSegmentCache, _hlsInitSegmentCache,
@ -191,10 +190,7 @@ public class HlsSessionWorker : IHlsSessionWorker
CancellationToken cancellationToken = _cancellationTokenSource.Token; CancellationToken cancellationToken = _cancellationTokenSource.Token;
_logger.LogInformation( _logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
"Starting HLS session for channel {Channel} with output format {OutputFormat}",
channelNumber,
_outputFormat);
if (_localFileSystem.ListFiles(_workingDirectory).Any()) if (_localFileSystem.ListFiles(_workingDirectory).Any())
{ {
@ -269,7 +265,7 @@ public class HlsSessionWorker : IHlsSessionWorker
try try
{ {
//_localFileSystem.EmptyFolder(_workingDirectory); _localFileSystem.EmptyFolder(_workingDirectory);
} }
catch catch
{ {
@ -447,10 +443,7 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
// fmp4 doesn't require pts offsets because each new item gets a discontinuity AND a new init segment // fmp4 doesn't require pts offsets because each new item gets a discontinuity AND a new init segment
long ptsOffset = _outputFormat is OutputFormatKind.HlsMp4 const long PTS_OFFSET = 0;
? 0
: await GetPtsOffset(_channelNumber, cancellationToken);
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
_logger.LogDebug("HLS session state: {State}", _state); _logger.LogDebug("HLS session state: {State}", _state);
@ -464,7 +457,7 @@ public class HlsSessionWorker : IHlsSessionWorker
startAtZero, startAtZero,
realtime, realtime,
_channelStart, _channelStart,
ptsOffset, PTS_OFFSET,
_targetFramerate); _targetFramerate);
// _logger.LogInformation("Request {@Request}", request); // _logger.LogInformation("Request {@Request}", request);
@ -487,6 +480,14 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
await TrimAndDelete(cancellationToken); await TrimAndDelete(cancellationToken);
// increment discontinuity sequence and store with segment key (generated at)
foreach (long segmentKey in processModel.SegmentKey)
{
_discontinuitySequence++;
_discontinuityMap.TryAdd(segmentKey, _discontinuitySequence);
//_logger.LogDebug("DISCONTINUITY MAP {Map}", _discontinuityMap);
}
Option<Pipe> maybePipe = Option<Pipe>.None; Option<Pipe> maybePipe = Option<Pipe>.None;
var stdErrBuffer = new StringBuilder(); var stdErrBuffer = new StringBuilder();
@ -527,7 +528,6 @@ public class HlsSessionWorker : IHlsSessionWorker
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds); processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
_transcodedUntil = processModel.Until; _transcodedUntil = processModel.Until;
_state = NextState(_state, processModel); _state = NextState(_state, processModel);
_hasWrittenSegments = true;
return true; return true;
} }
else else
@ -552,7 +552,7 @@ public class HlsSessionWorker : IHlsSessionWorker
_channelNumber, _channelNumber,
StreamingMode.HttpLiveStreamingSegmenter, StreamingMode.HttpLiveStreamingSegmenter,
realtime, realtime,
ptsOffset, PTS_OFFSET,
processModel.MaybeDuration, processModel.MaybeDuration,
processModel.Until, processModel.Until,
errorMessage), errorMessage),
@ -577,8 +577,6 @@ public class HlsSessionWorker : IHlsSessionWorker
_transcodedUntil = processModel.Until; _transcodedUntil = processModel.Until;
_state = NextState(_state, null); _state = NextState(_state, null);
_hasWrittenSegments = true;
return true; return true;
} }
} }
@ -649,7 +647,8 @@ public class HlsSessionWorker : IHlsSessionWorker
// trim playlist and insert discontinuity before appending with new ffmpeg process // trim playlist and insert discontinuity before appending with new ffmpeg process
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_outputFormat, _discontinuityMap,
OutputFormatKind.HlsMp4,
PlaylistStart, PlaylistStart,
DateTimeOffset.Now.AddMinutes(-1), DateTimeOffset.Now.AddMinutes(-1),
_hlsInitSegmentCache, _hlsInitSegmentCache,
@ -725,6 +724,7 @@ public class HlsSessionWorker : IHlsSessionWorker
toDelete.Add(init); toDelete.Add(init);
_hlsInitSegmentCache.DeleteSegment(fileName); _hlsInitSegmentCache.DeleteSegment(fileName);
_discontinuityMap.Remove(init.GeneratedAt);
} }
foreach (Segment segment in toDelete) foreach (Segment segment in toDelete)
@ -744,11 +744,6 @@ public class HlsSessionWorker : IHlsSessionWorker
private async Task RefreshInits() private async Task RefreshInits()
{ {
if (_outputFormat is not OutputFormatKind.HlsMp4)
{
return;
}
var allSegments = Directory.GetFiles(_workingDirectory, "live*.m4s") var allSegments = Directory.GetFiles(_workingDirectory, "live*.m4s")
.Map(Path.GetFileName) .Map(Path.GetFileName)
.Map(s => s.Split("_")[1]) .Map(s => s.Split("_")[1])
@ -764,41 +759,6 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
} }
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, PtsTime> queryResult = await _mediator.Send(
new GetLastPtsTime(channelNumber),
cancellationToken);
foreach (BaseError error in queryResult.LeftToSeq())
{
_logger.LogWarning("Unable to determine last pts offset - {Error}", error.ToString());
}
foreach (PtsTime pts in queryResult.RightToSeq())
{
result = pts.Value;
}
return result;
}
finally
{
_slim.Release();
}
}
private async Task<int> GetWorkAheadLimit(CancellationToken cancellationToken) => private async Task<int> GetWorkAheadLimit(CancellationToken cancellationToken) =>
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken) await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken)
.Map(maybeCount => maybeCount.Match(identity, () => 1)); .Map(maybeCount => maybeCount.Match(identity, () => 1));

3
ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs

@ -8,4 +8,5 @@ public record PlayoutItemProcessModel(
Option<GraphicsEngineContext> GraphicsEngineContext, Option<GraphicsEngineContext> GraphicsEngineContext,
Option<TimeSpan> MaybeDuration, Option<TimeSpan> MaybeDuration,
DateTimeOffset Until, DateTimeOffset Until,
bool IsComplete); bool IsComplete,
Option<long> SegmentKey);

3
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs

@ -43,6 +43,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
Option<TimeSpan>.None, Option<TimeSpan>.None,
DateTimeOffset.MaxValue, DateTimeOffset.MaxValue,
true); true,
Option<long>.None);
} }
} }

21
ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs

@ -8,16 +8,11 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess> public class GetErrorProcessHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: FFmpegProcessHandler<GetErrorProcess>(dbContextFactory)
{ {
private readonly IFFmpegProcessService _ffmpegProcessService;
public GetErrorProcessHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory) =>
_ffmpegProcessService = ffmpegProcessService;
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext, TvContext dbContext,
GetErrorProcess request, GetErrorProcess request,
@ -26,9 +21,12 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
string ffprobePath, string ffprobePath,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Command process = await _ffmpegProcessService.ForError( DateTimeOffset now = DateTimeOffset.Now;
Command process = await ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
request.MaybeDuration, request.MaybeDuration,
request.ErrorMessage, request.ErrorMessage,
request.HlsRealtime, request.HlsRealtime,
@ -43,6 +41,7 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
request.MaybeDuration, request.MaybeDuration,
request.Until, request.Until,
true); true,
now.ToUnixTimeSeconds());
} }
} }

5
ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs

@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Streaming;
public record GetLastPtsTime(string ChannelNumber) : IRequest<Either<BaseError, PtsTime>>;

170
ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs

@ -1,170 +0,0 @@
using System.Text;
using Bugsnag;
using CliWrap;
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.Logging;
using Newtonsoft.Json;
namespace ErsatzTV.Application.Streaming;
public class GetLastPtsTimeHandler : IRequestHandler<GetLastPtsTime, Either<BaseError, PtsTime>>
{
private readonly IClient _client;
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetLastPtsTimeHandler> _logger;
private readonly ITempFilePool _tempFilePool;
public GetLastPtsTimeHandler(
IClient client,
ILocalFileSystem localFileSystem,
ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository,
ILogger<GetLastPtsTimeHandler> logger)
{
_client = client;
_localFileSystem = localFileSystem;
_tempFilePool = tempFilePool;
_configElementRepository = configElementRepository;
_logger = logger;
}
public async Task<Either<BaseError, PtsTime>> Handle(
GetLastPtsTime request,
CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request, cancellationToken);
return await validation.Match(
parameters => Handle(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, PtsTime>>(error.Join()));
}
private async Task<Validation<BaseError, RequestParameters>> Validate(
GetLastPtsTime request,
CancellationToken cancellationToken) =>
await ValidateFFprobePath(cancellationToken)
.MapT(ffprobePath => new RequestParameters(request.ChannelNumber, ffprobePath));
private async Task<Either<BaseError, PtsTime>> Handle(
RequestParameters parameters,
CancellationToken cancellationToken)
{
Option<FileInfo> maybeLastSegment = GetLastSegment(parameters.ChannelNumber);
foreach (FileInfo segment in maybeLastSegment)
{
return await GetPts(parameters, segment, cancellationToken).IfNoneAsync(PtsTime.Zero);
}
return BaseError.New($"Failed to determine last pts duration for channel {parameters.ChannelNumber}");
}
private async Task<Option<PtsTime>> GetPts(
RequestParameters parameters,
FileInfo segment,
CancellationToken cancellationToken)
{
string[] argumentList =
{
"-v", "0",
"-show_entries",
"packet=pts,duration",
"-of", "compact=p=0:nk=1",
segment.FullName
};
string lastLine = string.Empty;
Action<string> replaceLine = s =>
{
if (!string.IsNullOrWhiteSpace(s))
{
lastLine = s.Trim();
}
};
CommandResult probe = await Cli.Wrap(parameters.FFprobePath)
.WithArguments(argumentList)
.WithValidation(CommandResultValidation.None)
.WithStandardOutputPipe(PipeTarget.ToDelegate(replaceLine))
.ExecuteAsync(cancellationToken);
if (probe.ExitCode != 0)
{
return Option<PtsTime>.None;
}
try
{
return PtsTime.From(lastLine);
}
catch (Exception ex)
{
_client.Notify(ex);
await SaveTroubleshootingData(parameters.ChannelNumber, lastLine);
}
return Option<PtsTime>.None;
}
private static Option<FileInfo> GetLastSegment(string channelNumber)
{
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
return Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault());
}
private Task<Validation<BaseError, string>> ValidateFFprobePath(CancellationToken cancellationToken) =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
.FilterT(File.Exists)
.Map(ffprobePath => ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private async Task SaveTroubleshootingData(string channelNumber, string output)
{
try
{
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
FileInfo[] allFiles = directory.GetFiles();
string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
string playlistContents = string.Empty;
if (_localFileSystem.FileExists(playlistFileName))
{
playlistContents = await File.ReadAllTextAsync(playlistFileName);
}
var data = new TroubleshootingData(allFiles, playlistContents, output);
string serialized = data.Serialize();
string file = _tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder);
await File.WriteAllTextAsync(file, serialized);
_logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
}
catch (Exception ex)
{
_client.Notify(ex);
}
}
private sealed record RequestParameters(string ChannelNumber, string FFprobePath);
private sealed record TroubleshootingData(IEnumerable<FileInfo> Files, string Playlist, string ProbeOutput)
{
public string Serialize()
{
var data = new InternalData(
Files.Map(f => new FileData(f.FullName, f.Length, f.LastWriteTimeUtc)).ToList(),
Convert.ToBase64String(Encoding.UTF8.GetBytes(Playlist)),
Convert.ToBase64String(Encoding.UTF8.GetBytes(ProbeOutput)));
return JsonConvert.SerializeObject(data);
}
private sealed record FileData(string FileName, long Bytes, DateTime LastWriteTimeUtc);
private sealed record InternalData(List<FileData> Files, string EncodedPlaylist, string EncodedProbeOutput);
}
}

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

@ -302,6 +302,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Command doesNotExistProcess = await _ffmpegProcessService.ForError( Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
duration, duration,
$"DEBUG_NO_SYNC:\n{Mapper.GetDisplayTitle(playoutItemWithPath.PlayoutItem.MediaItem, Option<string>.None)}\nFrom: {start} To: {finish}", $"DEBUG_NO_SYNC:\n{Mapper.GetDisplayTitle(playoutItemWithPath.PlayoutItem.MediaItem, Option<string>.None)}\nFrom: {start} To: {finish}",
request.HlsRealtime, request.HlsRealtime,
@ -316,7 +317,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
duration, duration,
finish, finish,
true); true,
now.ToUnixTimeSeconds());
} }
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion(); MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
@ -442,7 +444,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItemResult.GraphicsEngineContext, playoutItemResult.GraphicsEngineContext,
duration, duration,
finish, finish,
isComplete); isComplete,
effectiveNow.ToUnixTimeSeconds());
return Right<BaseError, PlayoutItemProcessModel>(result); return Right<BaseError, PlayoutItemProcessModel>(result);
} }
@ -473,6 +476,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Command offlineProcess = await _ffmpegProcessService.ForError( Command offlineProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
maybeDuration, maybeDuration,
"Channel is Offline", "Channel is Offline",
request.HlsRealtime, request.HlsRealtime,
@ -487,11 +491,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
maybeDuration, maybeDuration,
finish, finish,
true); true,
now.ToUnixTimeSeconds());
case PlayoutItemDoesNotExistOnDisk: case PlayoutItemDoesNotExistOnDisk:
Command doesNotExistProcess = await _ffmpegProcessService.ForError( Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
maybeDuration, maybeDuration,
error.Value, error.Value,
request.HlsRealtime, request.HlsRealtime,
@ -506,11 +512,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
maybeDuration, maybeDuration,
finish, finish,
true); true,
now.ToUnixTimeSeconds());
default: default:
Command errorProcess = await _ffmpegProcessService.ForError( Command errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
now,
maybeDuration, maybeDuration,
"Channel is Offline", "Channel is Offline",
request.HlsRealtime, request.HlsRealtime,
@ -525,7 +533,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
maybeDuration, maybeDuration,
finish, finish,
true); true,
now.ToUnixTimeSeconds());
} }
} }

3
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs

@ -44,6 +44,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
Option<GraphicsEngineContext>.None, Option<GraphicsEngineContext>.None,
Option<TimeSpan>.None, Option<TimeSpan>.None,
DateTimeOffset.MaxValue, DateTimeOffset.MaxValue,
true); true,
Option<long>.None);
} }
} }

671
ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs

File diff suppressed because it is too large Load Diff

3
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -550,6 +550,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public async Task<Command> ForError( public async Task<Command> ForError(
string ffmpegPath, string ffmpegPath,
Channel channel, Channel channel,
DateTimeOffset now,
Option<TimeSpan> duration, Option<TimeSpan> duration,
string errorMessage, string errorMessage,
bool hlsRealtime, bool hlsRealtime,
@ -626,7 +627,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8") ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
: Option<string>.None; : Option<string>.None;
long nowSeconds = DateTimeOffset.Now.ToUnixTimeSeconds(); long nowSeconds = now.ToUnixTimeSeconds();
Option<string> hlsSegmentTemplate = outputFormat switch Option<string> hlsSegmentTemplate = outputFormat switch
{ {

57
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

@ -1,4 +1,4 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.FFmpeg.OutputFormat; using ErsatzTV.FFmpeg.OutputFormat;
@ -9,6 +9,7 @@ namespace ErsatzTV.Core.FFmpeg;
public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFilter> logger) : IHlsPlaylistFilter public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFilter> logger) : IHlsPlaylistFilter
{ {
public TrimPlaylistResult TrimPlaylist( public TrimPlaylistResult TrimPlaylist(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,
@ -24,15 +25,10 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
DateTimeOffset currentTime = playlistStart; DateTimeOffset currentTime = playlistStart;
var targetDuration = 0; var targetDuration = 0;
var discontinuitySequence = 0;
var i = 0; var i = 0;
while (!lines[i].StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase)) while (i < lines.Length && !lines[i].StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase))
{ {
if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE", StringComparison.OrdinalIgnoreCase)) if (lines[i].Trim().Equals("#EXT-X-DISCONTINUITY", StringComparison.OrdinalIgnoreCase))
{
discontinuitySequence = int.Parse(lines[i].Split(':')[1], CultureInfo.InvariantCulture);
}
else if (lines[i].StartsWith("#EXT-X-DISCONTINUITY", StringComparison.OrdinalIgnoreCase))
{ {
items.Add(new PlaylistDiscontinuity()); items.Add(new PlaylistDiscontinuity());
} }
@ -53,7 +49,7 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
continue; continue;
} }
if (line.StartsWith("#EXT-X-DISCONTINUITY", StringComparison.OrdinalIgnoreCase)) if (line.Trim().Equals("#EXT-X-DISCONTINUITY", StringComparison.OrdinalIgnoreCase))
{ {
items.Add(new PlaylistDiscontinuity()); items.Add(new PlaylistDiscontinuity());
i++; i++;
@ -75,7 +71,7 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
long segmentNameTimeSeconds = long.MaxValue; long segmentNameTimeSeconds = long.MaxValue;
if (outputFormat is OutputFormatKind.HlsMp4) if (outputFormat is OutputFormatKind.HlsMp4)
{ {
if (!lines[i + 2].Contains('_') || !long.TryParse( if (i + 2 >= lines.Length || !lines[i + 2].Contains('_') || !long.TryParse(
lines[i + 2].Split('_')[1], lines[i + 2].Split('_')[1],
out segmentNameTimeSeconds)) out segmentNameTimeSeconds))
{ {
@ -89,19 +85,19 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
i += 3; i += 3;
} }
if (endWithDiscontinuity && items[^1] is not PlaylistDiscontinuity) if (endWithDiscontinuity && items.Count > 0 && items[^1] is not PlaylistDiscontinuity)
{ {
items.Add(new PlaylistDiscontinuity()); items.Add(new PlaylistDiscontinuity());
} }
(string playlist, DateTimeOffset nextPlaylistStart, long startSequence, long generatedAt, int segments) = (string playlist, DateTimeOffset nextPlaylistStart, long startSequence, long generatedAt, int segments) =
GeneratePlaylist( GeneratePlaylist(
discontinuityMap,
outputFormat, outputFormat,
items, items,
hlsInitSegmentCache, hlsInitSegmentCache,
filterBefore, filterBefore,
targetDuration, targetDuration,
discontinuitySequence,
maybeMaxSegments); maybeMaxSegments);
return new TrimPlaylistResult(nextPlaylistStart, startSequence, generatedAt, playlist, segments); return new TrimPlaylistResult(nextPlaylistStart, startSequence, generatedAt, playlist, segments);
@ -128,27 +124,23 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
} }
public TrimPlaylistResult TrimPlaylistWithDiscontinuity( public TrimPlaylistResult TrimPlaylistWithDiscontinuity(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,
IHlsInitSegmentCache hlsInitSegmentCache, IHlsInitSegmentCache hlsInitSegmentCache,
string[] lines) => string[] lines) =>
TrimPlaylist(outputFormat, playlistStart, filterBefore, hlsInitSegmentCache, lines, Option<int>.None, true); TrimPlaylist(discontinuityMap, outputFormat, playlistStart, filterBefore, hlsInitSegmentCache, lines, Option<int>.None, true);
private static Tuple<string, DateTimeOffset, long, long, int> GeneratePlaylist( private static Tuple<string, DateTimeOffset, long, long, int> GeneratePlaylist(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
List<PlaylistItem> items, List<PlaylistItem> items,
IHlsInitSegmentCache hlsInitSegmentCache, IHlsInitSegmentCache hlsInitSegmentCache,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,
int targetDuration, int targetDuration,
int discontinuitySequence,
Option<int> maybeMaxSegments) Option<int> maybeMaxSegments)
{ {
if (items.Count != 0 && items[0] is PlaylistDiscontinuity)
{
discontinuitySequence++;
}
while (items.Count != 0 && items[0] is PlaylistDiscontinuity) while (items.Count != 0 && items[0] is PlaylistDiscontinuity)
{ {
items.RemoveAt(0); items.RemoveAt(0);
@ -179,18 +171,25 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
long startSequence = 0; long startSequence = 0;
long generatedAt = 0; long generatedAt = 0;
int discontinuitySequence = 0;
foreach (var startSegment in allSegments.HeadOrNone()) foreach (var startSegment in allSegments.HeadOrNone())
{ {
startSequence = startSegment.StartSequence; startSequence = startSegment.StartSequence;
generatedAt = startSegment.GeneratedAt; generatedAt = startSegment.GeneratedAt;
if (discontinuityMap.TryGetValue(generatedAt, out int matchingSequence))
{
discontinuitySequence = matchingSequence;
}
} }
// count all discontinuities that were filtered out
if (allSegments.Count != 0) if (allSegments.Count != 0)
{ {
int index = items.IndexOf(allSegments.Head()); // remove anything after the last segment
int count = items.Take(index + 1).OfType<PlaylistDiscontinuity>().Count(); if (maybeMaxSegments.IsSome)
discontinuitySequence += count; {
int index = items.IndexOf(allSegments.Last());
items.RemoveRange(index + 1, items.Count - index - 1);
}
} }
var output = new StringBuilder(); var output = new StringBuilder();
@ -198,9 +197,19 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
output.AppendLine("#EXT-X-VERSION:7"); output.AppendLine("#EXT-X-VERSION:7");
output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-TARGETDURATION:{targetDuration}"); output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-TARGETDURATION:{targetDuration}");
output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-MEDIA-SEQUENCE:{startSequence}"); output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
if (discontinuitySequence > 1)
{
output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
}
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS"); output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
if (discontinuitySequence == 1)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
if (outputFormat is OutputFormatKind.HlsMp4) if (outputFormat is OutputFormatKind.HlsMp4)
{ {
output.AppendLine( output.AppendLine(

4
ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs

@ -1,10 +1,11 @@
using ErsatzTV.FFmpeg.OutputFormat; using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
public interface IHlsPlaylistFilter public interface IHlsPlaylistFilter
{ {
TrimPlaylistResult TrimPlaylist( TrimPlaylistResult TrimPlaylist(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,
@ -14,6 +15,7 @@ public interface IHlsPlaylistFilter
bool endWithDiscontinuity = false); bool endWithDiscontinuity = false);
TrimPlaylistResult TrimPlaylistWithDiscontinuity( TrimPlaylistResult TrimPlaylistWithDiscontinuity(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
DateTimeOffset filterBefore, DateTimeOffset filterBefore,

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

@ -46,6 +46,7 @@ public interface IFFmpegProcessService
Task<Command> ForError( Task<Command> ForError(
string ffmpegPath, string ffmpegPath,
Channel channel, Channel channel,
DateTimeOffset now,
Option<TimeSpan> duration, Option<TimeSpan> duration,
string errorMessage, string errorMessage,
bool hlsRealtime, bool hlsRealtime,

4
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -14,7 +14,7 @@
<PackageReference Include="CliWrap" Version="3.9.0" /> <PackageReference Include="CliWrap" Version="3.9.0" />
<PackageReference Include="Dapper" Version="2.1.66" /> <PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" /> <PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.1.10" /> <PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.1.11" />
<PackageReference Include="Jint" Version="4.4.1" /> <PackageReference Include="Jint" Version="4.4.1" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00017" /> <PackageReference Include="Lucene.Net" Version="4.8.0-beta00017" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" /> <PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
@ -38,7 +38,7 @@
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SkiaSharp" Version="3.119.1" /> <PackageReference Include="SkiaSharp" Version="3.119.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" /> <PackageReference Include="TagLibSharp" Version="2.3.0" />
<PackageReference Include="TimeZoneConverter" Version="7.0.0" /> <PackageReference Include="TimeZoneConverter" Version="7.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

Loading…
Cancel
Save