diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed3c6e49..3861cf445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - This affected external logo urls and generated channel logos - Return a retry response instead of `404` when an HLS segmenter playlist is temporarily unavailable - Previously, a client that requested the playlist before the session had written it was told the stream did not exist +- Treat a playlist that fails to trim as unavailable instead of serving an empty playlist + - Previously, a malformed playlist produced an empty response with a reset media sequence, which could glitch or stall players + - The failed trim also no longer overwrites the playlist on disk, so the session can recover on the next cycle ## [26.7.0] - 2026-07-27 ### Added diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index 01bc4e741..d53f21c95 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -125,7 +125,7 @@ public class HlsSessionWorker : IHlsSessionWorker { await RefreshInits(); - TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist( + Option maybeTrimResult = _hlsPlaylistFilter.TrimPlaylist( _discontinuityMap, _outputFormatKind, PlaylistStart, @@ -133,13 +133,16 @@ public class HlsSessionWorker : IHlsSessionWorker _hlsInitSegmentCache, input, maybeMaxSegments: 10); - if (DateTimeOffset.Now > _lastDelete.AddSeconds(30)) + foreach (TrimPlaylistResult trimResult in maybeTrimResult) { - DeleteOldSegments(trimResult); - _lastDelete = DateTimeOffset.Now; + if (DateTimeOffset.Now > _lastDelete.AddSeconds(30)) + { + DeleteOldSegments(trimResult); + _lastDelete = DateTimeOffset.Now; + } } - return trimResult; + return maybeTrimResult; } _logger.LogWarning("HlsSessionWorker.TrimPlaylist read empty playlist?"); @@ -691,18 +694,23 @@ public class HlsSessionWorker : IHlsSessionWorker await RefreshInits(); // trim playlist and insert discontinuity before appending with new ffmpeg process - TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( + Option maybeTrimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( _discontinuityMap, _outputFormatKind, PlaylistStart, DateTimeOffset.Now.AddMinutes(-1), _hlsInitSegmentCache, lines); - await WritePlaylist(trimResult.Playlist, cancellationToken); - DeleteOldSegments(trimResult); + // on trim failure, leave the existing playlist untouched + foreach (TrimPlaylistResult trimResult in maybeTrimResult) + { + await WritePlaylist(trimResult.Playlist, cancellationToken); - PlaylistStart = trimResult.PlaylistStart; + DeleteOldSegments(trimResult); + + PlaylistStart = trimResult.PlaylistStart; + } } } finally diff --git a/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs b/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs index ad6777624..2c7cbd702 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs @@ -42,7 +42,7 @@ live_1760874038_001138.m4s #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live_1760874038_001139.m4s").Split(Environment.NewLine); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist( new Dictionary { [1760874038] = 1 @@ -52,7 +52,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); start.AddSeconds(-30), new FakeHlsInitSegmentCache(), input, - maybeMaxSegments: 10); + maybeMaxSegments: 10)); result.PlaylistStart.ShouldBe(start); result.Sequence.ShouldBe(1137); @@ -99,7 +99,7 @@ live_1760874038_001138.m4s #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live_1760874038_001139.m4s").Split(Environment.NewLine); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist( new Dictionary { [1760874038] = 1 @@ -109,7 +109,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); start.AddSeconds(-30), new FakeHlsInitSegmentCache(), input, - 2); + 2)); result.PlaylistStart.ShouldBe(start); result.Sequence.ShouldBe(1137); @@ -153,7 +153,7 @@ live_1760874038_001138.m4s #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live_1760874038_001139.m4s").Split(Environment.NewLine); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( new Dictionary { [1760874038] = 1 @@ -162,7 +162,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); start, start.AddSeconds(-30), new FakeHlsInitSegmentCache(), - input); + input)); result.PlaylistStart.ShouldBe(start); result.Sequence.ShouldBe(1137); @@ -210,7 +210,7 @@ live_1760874038_001138.m4s #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live_1760874038_001139.m4s").Split(Environment.NewLine); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist( new Dictionary { [1760874038] = 1 @@ -220,7 +220,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); start.AddSeconds(6), new FakeHlsInitSegmentCache(), input, - 1); + 1)); result.PlaylistStart.ShouldBe(start.AddSeconds(8)); result.Sequence.ShouldBe(1139); @@ -262,7 +262,7 @@ live_1760874042_001138.m4s #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live_1760874042_001139.m4s").Split(Environment.NewLine); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist( new Dictionary { [1760874038] = 1, @@ -273,7 +273,7 @@ live_1760874042_001139.m4s").Split(Environment.NewLine); start.AddSeconds(6), new FakeHlsInitSegmentCache(), input, - maybeMaxSegments: 10); + maybeMaxSegments: 10)); result.PlaylistStart.ShouldBe(start); result.Sequence.ShouldBe(1137); @@ -564,7 +564,7 @@ live_1760874138_000081.m4s #EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:26.007-0500 live_1760874138_000082.m4s").Split(Environment.NewLine); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist( new Dictionary { [1760874038] = 1, @@ -575,7 +575,7 @@ live_1760874138_000082.m4s").Split(Environment.NewLine); start.AddSeconds(220), new FakeHlsInitSegmentCache(), input, - maybeMaxSegments: 10); + maybeMaxSegments: 10)); // result.PlaylistStart.ShouldBe(start); result.Sequence.ShouldBe(56); @@ -648,7 +648,7 @@ live_1760874040_000048.m4s // filter 'live000046.m4s' var filterBefore = start.AddSeconds(2); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist( new Dictionary { [1760874038] = 2, @@ -659,7 +659,7 @@ live_1760874040_000048.m4s filterBefore, new FakeHlsInitSegmentCache(), input, - 2); + 2)); result.Sequence.ShouldBe(47); @@ -782,7 +782,7 @@ live_1760874038_000064.m4s // filter 'live000046.m4s' var filterBefore = start.AddSeconds(32); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist( new Dictionary { [1760874038] = 2 @@ -792,7 +792,7 @@ live_1760874038_000064.m4s filterBefore, new FakeHlsInitSegmentCache(), input, - 10); + 10)); result.Sequence.ShouldBe(46); @@ -939,7 +939,7 @@ live_1760874038_000064.m4s // filter 'live000046.m4s' var filterBefore = start.AddSeconds(36); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist( new Dictionary { [1760874038] = 2 @@ -949,7 +949,7 @@ live_1760874038_000064.m4s filterBefore, new FakeHlsInitSegmentCache(), input, - 10); + 10)); result.Sequence.ShouldBe(47); @@ -1021,7 +1021,7 @@ live_1760874038_001138.m4s #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 live_1760874038_001139.m4s").Split(Environment.NewLine); - TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( + TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( new Dictionary { [1760874038] = 1 @@ -1030,12 +1030,43 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); start, start.AddSeconds(60), new FakeHlsInitSegmentCache(), - input); + input)); result.SegmentCount.ShouldBeGreaterThan(0); result.Playlist.ShouldContain("#EXTINF"); } + [Test] + public void HlsPlaylistFilter_ShouldReturnNone_WhenPlaylistIsMalformed() + { + // a malformed playlist used to produce an "empty" result (no segments, sequence 0) + // that callers served to players and wrote over the on-disk playlist + var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); + string[] input = NormalizeLineEndings( + @"#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:4 +#EXTINF:not-a-number, +live_1760874038_001137.m4s").Split(Environment.NewLine); + + Option result = _hlsPlaylistFilter.TrimPlaylist( + new Dictionary(), + OutputFormatKind.HlsMp4, + start, + start.AddSeconds(-30), + new FakeHlsInitSegmentCache(), + input, + maybeMaxSegments: 10); + + result.IsNone.ShouldBeTrue(); + } + + private static TrimPlaylistResult Unwrap(Option maybeResult) + { + maybeResult.IsSome.ShouldBeTrue(); + return maybeResult.IfNone(() => throw new InvalidOperationException("expected trim playlist result")); + } + private static string NormalizeLineEndings(string str) => str .Replace("\r\n", "\n") diff --git a/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs b/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs index 740ce1c5b..13738e568 100644 --- a/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs +++ b/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs @@ -8,7 +8,7 @@ namespace ErsatzTV.Core.FFmpeg; public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger logger) : IHlsPlaylistFilter { - public TrimPlaylistResult TrimPlaylist( + public Option TrimPlaylist( Dictionary discontinuityMap, OutputFormatKind outputFormat, DateTimeOffset playlistStart, @@ -110,20 +110,18 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger.None; } } - public TrimPlaylistResult TrimPlaylistWithDiscontinuity( + public Option TrimPlaylistWithDiscontinuity( Dictionary discontinuityMap, OutputFormatKind outputFormat, DateTimeOffset playlistStart, diff --git a/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs b/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs index fc7b33286..4b1159355 100644 --- a/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs +++ b/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs @@ -4,7 +4,7 @@ namespace ErsatzTV.Core.FFmpeg; public interface IHlsPlaylistFilter { - TrimPlaylistResult TrimPlaylist( + Option TrimPlaylist( Dictionary discontinuityMap, OutputFormatKind outputFormat, DateTimeOffset playlistStart, @@ -14,7 +14,7 @@ public interface IHlsPlaylistFilter Option maybeMaxSegments, bool endWithDiscontinuity = false); - TrimPlaylistResult TrimPlaylistWithDiscontinuity( + Option TrimPlaylistWithDiscontinuity( Dictionary discontinuityMap, OutputFormatKind outputFormat, DateTimeOffset playlistStart,