From ff3a1d33864323756c9fd5a87a6d05278ef45f6b Mon Sep 17 00:00:00 2001 From: Ministorm3 <4474921+Ministorm3@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:04:53 -0400 Subject: [PATCH] fix: return no result instead of empty playlist when trim fails HlsPlaylistFilter.TrimPlaylist caught parse failures and returned an "empty" result: no segments, media sequence 0, empty playlist text. Callers treated that as success: the session endpoint served the empty playlist as 200, and TrimAndDelete wrote it over live.m3u8 on disk, turning a transient parse error into persistent corruption. Return Option with None on failure instead. The session endpoint now answers with the 503 retry response, and TrimAndDelete leaves the existing playlist untouched so the session can recover on the next cycle. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 + .../Streaming/HlsSessionWorker.cs | 26 ++++--- .../FFmpeg/HlsPlaylistFilterTests.cs | 71 +++++++++++++------ ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs | 10 ++- ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs | 4 +- 5 files changed, 77 insertions(+), 37 deletions(-) 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,