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

80
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

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

3
ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs

@ -8,4 +8,5 @@ public record PlayoutItemProcessModel( @@ -8,4 +8,5 @@ public record PlayoutItemProcessModel(
Option<GraphicsEngineContext> GraphicsEngineContext,
Option<TimeSpan> MaybeDuration,
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 @@ -43,6 +43,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
Option<GraphicsEngineContext>.None,
Option<TimeSpan>.None,
DateTimeOffset.MaxValue,
true);
true,
Option<long>.None);
}
}

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

@ -8,16 +8,11 @@ using Microsoft.EntityFrameworkCore; @@ -8,16 +8,11 @@ using Microsoft.EntityFrameworkCore;
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(
TvContext dbContext,
GetErrorProcess request,
@ -26,9 +21,12 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess> @@ -26,9 +21,12 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
string ffprobePath,
CancellationToken cancellationToken)
{
Command process = await _ffmpegProcessService.ForError(
DateTimeOffset now = DateTimeOffset.Now;
Command process = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
now,
request.MaybeDuration,
request.ErrorMessage,
request.HlsRealtime,
@ -43,6 +41,7 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess> @@ -43,6 +41,7 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
Option<GraphicsEngineContext>.None,
request.MaybeDuration,
request.Until,
true);
true,
now.ToUnixTimeSeconds());
}
}

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

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

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

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

57
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

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

4
ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs

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

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

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

4
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
<PackageReference Include="CliWrap" Version="3.9.0" />
<PackageReference Include="Dapper" Version="2.1.66" />
<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="Lucene.Net" Version="4.8.0-beta00017" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
@ -38,7 +38,7 @@ @@ -38,7 +38,7 @@
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SkiaSharp" Version="3.119.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
<PackageReference Include="TimeZoneConverter" Version="7.0.0" />
<PackageReference Include="TimeZoneConverter" Version="7.1.0" />
</ItemGroup>
<ItemGroup>

Loading…
Cancel
Save