diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs index ddc81af27..a76db4b68 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs @@ -22,6 +22,7 @@ namespace ErsatzTV.Application.Streaming.Commands private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IConfigElementRepository _configElementRepository; + private readonly IHlsPlaylistFilter _hlsPlaylistFilter; private readonly ILocalFileSystem _localFileSystem; public StartFFmpegSessionHandler( @@ -29,13 +30,15 @@ namespace ErsatzTV.Application.Streaming.Commands ILogger logger, IServiceScopeFactory serviceScopeFactory, IFFmpegSegmenterService ffmpegSegmenterService, - IConfigElementRepository configElementRepository) + IConfigElementRepository configElementRepository, + IHlsPlaylistFilter hlsPlaylistFilter) { _localFileSystem = localFileSystem; _logger = logger; _serviceScopeFactory = serviceScopeFactory; _ffmpegSegmenterService = ffmpegSegmenterService; _configElementRepository = configElementRepository; + _hlsPlaylistFilter = hlsPlaylistFilter; } public Task> Handle(StartFFmpegSession request, CancellationToken cancellationToken) => @@ -78,7 +81,7 @@ namespace ErsatzTV.Application.Streaming.Commands return Unit.Default; } - private static async Task WaitForPlaylistSegments(string playlistFileName, int initialSegmentCount, IHlsSessionWorker worker) + private async Task WaitForPlaylistSegments(string playlistFileName, int initialSegmentCount, IHlsSessionWorker worker) { while (!File.Exists(playlistFileName)) { @@ -92,7 +95,7 @@ namespace ErsatzTV.Application.Streaming.Commands DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30); string[] input = await File.ReadAllLinesAsync(playlistFileName); - TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(worker.PlaylistStart, now, input); + TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(worker.PlaylistStart, now, input); segmentCount = result.SegmentCount; } } diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index af2cbbac1..dce27c778 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -24,6 +24,7 @@ namespace ErsatzTV.Application.Streaming public class HlsSessionWorker : IHlsSessionWorker { private static int _workAheadCount; + private readonly IHlsPlaylistFilter _hlsPlaylistFilter; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ILogger _logger; private DateTimeOffset _lastAccess; @@ -33,8 +34,9 @@ namespace ErsatzTV.Application.Streaming private DateTimeOffset _playlistStart; private Option _targetFramerate; - public HlsSessionWorker(IServiceScopeFactory serviceScopeFactory, ILogger logger) + public HlsSessionWorker(IHlsPlaylistFilter hlsPlaylistFilter, IServiceScopeFactory serviceScopeFactory, ILogger logger) { + _hlsPlaylistFilter = hlsPlaylistFilter; _serviceScopeFactory = serviceScopeFactory; _logger = logger; } @@ -123,7 +125,11 @@ namespace ErsatzTV.Application.Streaming } } - private async Task Transcode(string channelNumber, bool firstProcess, bool realtime, CancellationToken cancellationToken) + private async Task Transcode( + string channelNumber, + bool firstProcess, + bool realtime, + CancellationToken cancellationToken) { try { @@ -212,7 +218,7 @@ namespace ErsatzTV.Application.Streaming return true; } - + private async Task TrimAndDelete(string channelNumber, CancellationToken cancellationToken) { string playlistFileName = Path.Combine( @@ -224,7 +230,7 @@ namespace ErsatzTV.Application.Streaming { // trim playlist and insert discontinuity before appending with new ffmpeg process string[] lines = await File.ReadAllLinesAsync(playlistFileName, cancellationToken); - TrimPlaylistResult trimResult = HlsPlaylistFilter.TrimPlaylistWithDiscontinuity( + TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( _playlistStart, DateTimeOffset.Now.AddMinutes(-1), lines); @@ -246,13 +252,13 @@ namespace ErsatzTV.Application.Streaming var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList(); // if (toDelete.Count > 0) // { - // _logger.LogInformation( - // "Deleting HLS segments {Min} to {Max} (less than {StartSequence})", - // toDelete.Map(s => s.SequenceNumber).Min(), - // toDelete.Map(s => s.SequenceNumber).Max(), - // trimResult.Sequence); + // _logger.LogInformation( + // "Deleting HLS segments {Min} to {Max} (less than {StartSequence})", + // toDelete.Map(s => s.SequenceNumber).Min(), + // toDelete.Map(s => s.SequenceNumber).Max(), + // trimResult.Sequence); // } - + foreach (Segment segment in toDelete) { File.Delete(segment.File); @@ -261,7 +267,7 @@ namespace ErsatzTV.Application.Streaming _playlistStart = trimResult.PlaylistStart; } } - + private async Task GetPtsOffset(IMediator mediator, string channelNumber, CancellationToken cancellationToken) { var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber)); diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj index 6267c2985..1ffa8117e 100644 --- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj +++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs b/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs index 8930e154f..755e3d891 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs @@ -1,6 +1,9 @@ using System; using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.FFmpeg; using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; using NUnit.Framework; namespace ErsatzTV.Core.Tests.FFmpeg @@ -8,8 +11,19 @@ namespace ErsatzTV.Core.Tests.FFmpeg [TestFixture] public class HlsPlaylistFilterTests { + private HlsPlaylistFilter _hlsPlaylistFilter; + + [SetUp] + public void SetUp() + { + _hlsPlaylistFilter = new HlsPlaylistFilter( + new Mock().Object, + new Mock>().Object + ); + } + [Test] - public void HlsPlaylistFilter_ShouldRewriteProgramDateTime() + public void _hlsPlaylistFilter_ShouldRewriteProgramDateTime() { var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); string[] input = NormalizeLineEndings(@"#EXTM3U @@ -28,7 +42,7 @@ live001138.ts #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live001139.ts").Split(Environment.NewLine); - TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input); + TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input); result.PlaylistStart.Should().Be(start); result.Sequence.Should().Be(1137); @@ -53,7 +67,7 @@ live001139.ts } [Test] - public void HlsPlaylistFilter_ShouldLimitSegments() + public void _hlsPlaylistFilter_ShouldLimitSegments() { var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); string[] input = NormalizeLineEndings(@"#EXTM3U @@ -72,7 +86,7 @@ live001138.ts #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live001139.ts").Split(Environment.NewLine); - TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2); + TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2); result.PlaylistStart.Should().Be(start); result.Sequence.Should().Be(1137); @@ -94,7 +108,7 @@ live001138.ts } [Test] - public void HlsPlaylistFilter_ShouldAddDiscontinuity() + public void _hlsPlaylistFilter_ShouldAddDiscontinuity() { var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); string[] input = NormalizeLineEndings(@"#EXTM3U @@ -113,7 +127,7 @@ live001138.ts #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live001139.ts").Split(Environment.NewLine); - TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist( + TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( start, start.AddSeconds(-30), input, @@ -144,7 +158,7 @@ live001139.ts } [Test] - public void HlsPlaylistFilter_ShouldFilterOldSegments() + public void _hlsPlaylistFilter_ShouldFilterOldSegments() { var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); string[] input = NormalizeLineEndings(@"#EXTM3U @@ -163,7 +177,7 @@ live001138.ts #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live001139.ts").Split(Environment.NewLine); - TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input); + TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input); result.PlaylistStart.Should().Be(start.AddSeconds(8)); result.Sequence.Should().Be(1139); @@ -182,7 +196,7 @@ live001139.ts } [Test] - public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity() + public void _hlsPlaylistFilter_ShouldFilterOldDiscontinuity() { var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); string[] input = NormalizeLineEndings(@"#EXTM3U @@ -202,7 +216,7 @@ live001138.ts #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live001139.ts").Split(Environment.NewLine); - TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input); + TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input); result.PlaylistStart.Should().Be(start.AddSeconds(8)); result.Sequence.Should().Be(1139); diff --git a/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs b/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs index 7d9bac71e..6844dc69d 100644 --- a/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs +++ b/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs @@ -1,110 +1,144 @@ using System; using System.Globalization; +using System.IO; using System.Text; +using ErsatzTV.Core.Interfaces.FFmpeg; +using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.FFmpeg { - public class HlsPlaylistFilter + public class HlsPlaylistFilter : IHlsPlaylistFilter { - public static TrimPlaylistResult TrimPlaylist( + private readonly ITempFilePool _tempFilePool; + private readonly ILogger _logger; + + public HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger logger) + { + _tempFilePool = tempFilePool; + _logger = logger; + } + + public TrimPlaylistResult TrimPlaylist( DateTimeOffset playlistStart, DateTimeOffset filterBefore, string[] lines, int maxSegments = 10, bool endWithDiscontinuity = false) { - DateTimeOffset currentTime = playlistStart; - DateTimeOffset nextPlaylistStart = DateTimeOffset.MaxValue; - - var discontinuitySequence = 0; - var startSequence = 0; - var output = new StringBuilder(); - var started = false; - var i = 0; - var segments = 0; - while (!lines[i].StartsWith("#EXTINF:")) + try { - if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE")) - { - discontinuitySequence = int.Parse(lines[i].Split(':')[1]); - } - - i++; - } + DateTimeOffset currentTime = playlistStart; + DateTimeOffset nextPlaylistStart = DateTimeOffset.MaxValue; - while (i < lines.Length) - { - if (segments >= maxSegments) + var discontinuitySequence = 0; + var startSequence = 0; + var output = new StringBuilder(); + var started = false; + var i = 0; + var segments = 0; + while (!lines[i].StartsWith("#EXTINF:")) { - break; + if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE")) + { + discontinuitySequence = int.Parse(lines[i].Split(':')[1]); + } + + i++; } - string line = lines[i]; - // _logger.LogInformation("Line: {Line}", line); - if (line.StartsWith("#EXT-X-DISCONTINUITY")) + while (i < lines.Length) { - if (started) + if (segments >= maxSegments) { - output.AppendLine("#EXT-X-DISCONTINUITY"); + break; } - else + + string line = lines[i]; + // _logger.LogInformation("Line: {Line}", line); + if (line.StartsWith("#EXT-X-DISCONTINUITY")) { - discontinuitySequence++; + if (started) + { + output.AppendLine("#EXT-X-DISCONTINUITY"); + } + else + { + discontinuitySequence++; + } + + i++; + continue; } - i++; - continue; - } + var duration = TimeSpan.FromSeconds( + double.Parse( + lines[i].TrimEnd(',').Split(':')[1], + NumberStyles.Number, + CultureInfo.InvariantCulture)); + if (currentTime < filterBefore) + { + currentTime += duration; + i += 3; + continue; + } + + nextPlaylistStart = currentTime < nextPlaylistStart ? currentTime : nextPlaylistStart; + + if (!started) + { + startSequence = int.Parse(lines[i + 2].Replace("live", string.Empty).Split('.')[0]); + + output.AppendLine("#EXTM3U"); + output.AppendLine("#EXT-X-VERSION:6"); + output.AppendLine("#EXT-X-TARGETDURATION:4"); + output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}"); + output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}"); + output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS"); + output.AppendLine("#EXT-X-DISCONTINUITY"); + + started = true; + } + + output.AppendLine(lines[i]); + string offset = currentTime.ToString("zzz").Replace(":", string.Empty); + output.AppendLine($"#EXT-X-PROGRAM-DATE-TIME:{currentTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}"); + output.AppendLine(lines[i + 2]); - var duration = TimeSpan.FromSeconds( - double.Parse( - lines[i].TrimEnd(',').Split(':')[1], - NumberStyles.Number, - CultureInfo.InvariantCulture)); - if (currentTime < filterBefore) - { currentTime += duration; + segments++; i += 3; - continue; } - - nextPlaylistStart = currentTime < nextPlaylistStart ? currentTime : nextPlaylistStart; - if (!started) + var playlist = output.ToString(); + if (endWithDiscontinuity && !playlist.EndsWith($"#EXT-X-DISCONTINUITY{Environment.NewLine}")) { - startSequence = int.Parse(lines[i + 2].Replace("live", string.Empty).Split('.')[0]); - - output.AppendLine("#EXTM3U"); - output.AppendLine("#EXT-X-VERSION:6"); - output.AppendLine("#EXT-X-TARGETDURATION:4"); - output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}"); - output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}"); - output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS"); - output.AppendLine("#EXT-X-DISCONTINUITY"); - - started = true; + playlist += "#EXT-X-DISCONTINUITY" + Environment.NewLine; } - output.AppendLine(lines[i]); - string offset = currentTime.ToString("zzz").Replace(":", string.Empty); - output.AppendLine($"#EXT-X-PROGRAM-DATE-TIME:{currentTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}"); - output.AppendLine(lines[i + 2]); - - currentTime += duration; - segments++; - i += 3; + return new TrimPlaylistResult(nextPlaylistStart, startSequence, playlist, segments); } - - var playlist = output.ToString(); - if (endWithDiscontinuity && !playlist.EndsWith($"#EXT-X-DISCONTINUITY{Environment.NewLine}")) + catch (Exception ex) { - playlist += "#EXT-X-DISCONTINUITY" + Environment.NewLine; - } + try + { + string file = _tempFilePool.GetNextTempFile(TempFileCategory.BadPlaylist); + File.WriteAllLines(file, lines); - return new TrimPlaylistResult(nextPlaylistStart, startSequence, playlist, segments); + _logger.LogError(ex, "Error filtering playlist. Bad playlist saved to {BadPlaylistFile}", file); + + // TODO: better error result? + return new TrimPlaylistResult(playlistStart, 0, string.Empty, 0); + } + catch + { + // do nothing + } + + throw; + } } - public static TrimPlaylistResult TrimPlaylistWithDiscontinuity( + public TrimPlaylistResult TrimPlaylistWithDiscontinuity( DateTimeOffset playlistStart, DateTimeOffset filterBefore, string[] lines) diff --git a/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs b/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs new file mode 100644 index 000000000..10930c5e3 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs @@ -0,0 +1,18 @@ +using System; + +namespace ErsatzTV.Core.FFmpeg; + +public interface IHlsPlaylistFilter +{ + TrimPlaylistResult TrimPlaylist( + DateTimeOffset playlistStart, + DateTimeOffset filterBefore, + string[] lines, + int maxSegments = 10, + bool endWithDiscontinuity = false); + + TrimPlaylistResult TrimPlaylistWithDiscontinuity( + DateTimeOffset playlistStart, + DateTimeOffset filterBefore, + string[] lines); +} diff --git a/ErsatzTV.Core/FFmpeg/TempFileCategory.cs b/ErsatzTV.Core/FFmpeg/TempFileCategory.cs index 207cf9033..24a581109 100644 --- a/ErsatzTV.Core/FFmpeg/TempFileCategory.cs +++ b/ErsatzTV.Core/FFmpeg/TempFileCategory.cs @@ -5,6 +5,8 @@ Subtitle = 0, SongBackground = 1, CoverArt = 2, - CachedArtwork = 3 + CachedArtwork = 3, + + BadPlaylist = 99 } } diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 8297a9bcf..b1d776db3 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -26,17 +26,20 @@ namespace ErsatzTV.Controllers public class IptvController : ControllerBase { private readonly IFFmpegSegmenterService _ffmpegSegmenterService; + private readonly IHlsPlaylistFilter _hlsPlaylistFilter; private readonly ILogger _logger; private readonly IMediator _mediator; public IptvController( IMediator mediator, ILogger logger, - IFFmpegSegmenterService ffmpegSegmenterService) + IFFmpegSegmenterService ffmpegSegmenterService, + IHlsPlaylistFilter hlsPlaylistFilter) { _mediator = mediator; _logger = logger; _ffmpegSegmenterService = ffmpegSegmenterService; + _hlsPlaylistFilter = hlsPlaylistFilter; } [HttpGet("iptv/channels.m3u")] @@ -97,7 +100,7 @@ namespace ErsatzTV.Controllers // worker.PlaylistStart, // now); - TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(worker.PlaylistStart, now, input); + TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(worker.PlaylistStart, now, input); return Content(result.Playlist, "application/vnd.apple.mpegurl"); } } diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 20452a184..344acd67d 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -254,6 +254,7 @@ namespace ErsatzTV services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); AddChannel(services); AddChannel(services); AddChannel(services);