Browse Source

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<TrimPlaylistResult> 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 <noreply@anthropic.com>
pull/2963/head
Ministorm3 3 days ago
parent
commit
ff3a1d3386
  1. 3
      CHANGELOG.md
  2. 26
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  3. 71
      ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs
  4. 10
      ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs
  5. 4
      ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs

3
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 - This affected external logo urls and generated channel logos
- Return a retry response instead of `404` when an HLS segmenter playlist is temporarily unavailable - 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 - 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 ## [26.7.0] - 2026-07-27
### Added ### Added

26
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -125,7 +125,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
await RefreshInits(); await RefreshInits();
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist( Option<TrimPlaylistResult> maybeTrimResult = _hlsPlaylistFilter.TrimPlaylist(
_discontinuityMap, _discontinuityMap,
_outputFormatKind, _outputFormatKind,
PlaylistStart, PlaylistStart,
@ -133,13 +133,16 @@ public class HlsSessionWorker : IHlsSessionWorker
_hlsInitSegmentCache, _hlsInitSegmentCache,
input, input,
maybeMaxSegments: 10); maybeMaxSegments: 10);
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30)) foreach (TrimPlaylistResult trimResult in maybeTrimResult)
{ {
DeleteOldSegments(trimResult); if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
_lastDelete = DateTimeOffset.Now; {
DeleteOldSegments(trimResult);
_lastDelete = DateTimeOffset.Now;
}
} }
return trimResult; return maybeTrimResult;
} }
_logger.LogWarning("HlsSessionWorker.TrimPlaylist read empty playlist?"); _logger.LogWarning("HlsSessionWorker.TrimPlaylist read empty playlist?");
@ -691,18 +694,23 @@ public class HlsSessionWorker : IHlsSessionWorker
await RefreshInits(); await RefreshInits();
// 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( Option<TrimPlaylistResult> maybeTrimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_discontinuityMap, _discontinuityMap,
_outputFormatKind, _outputFormatKind,
PlaylistStart, PlaylistStart,
DateTimeOffset.Now.AddMinutes(-1), DateTimeOffset.Now.AddMinutes(-1),
_hlsInitSegmentCache, _hlsInitSegmentCache,
lines); 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 finally

71
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 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live_1760874038_001139.m4s").Split(Environment.NewLine); live_1760874038_001139.m4s").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 1 [1760874038] = 1
@ -52,7 +52,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine);
start.AddSeconds(-30), start.AddSeconds(-30),
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input, input,
maybeMaxSegments: 10); maybeMaxSegments: 10));
result.PlaylistStart.ShouldBe(start); result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(1137); result.Sequence.ShouldBe(1137);
@ -99,7 +99,7 @@ live_1760874038_001138.m4s
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live_1760874038_001139.m4s").Split(Environment.NewLine); live_1760874038_001139.m4s").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 1 [1760874038] = 1
@ -109,7 +109,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine);
start.AddSeconds(-30), start.AddSeconds(-30),
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input, input,
2); 2));
result.PlaylistStart.ShouldBe(start); result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(1137); result.Sequence.ShouldBe(1137);
@ -153,7 +153,7 @@ live_1760874038_001138.m4s
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live_1760874038_001139.m4s").Split(Environment.NewLine); live_1760874038_001139.m4s").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 1 [1760874038] = 1
@ -162,7 +162,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine);
start, start,
start.AddSeconds(-30), start.AddSeconds(-30),
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input); input));
result.PlaylistStart.ShouldBe(start); result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(1137); result.Sequence.ShouldBe(1137);
@ -210,7 +210,7 @@ live_1760874038_001138.m4s
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live_1760874038_001139.m4s").Split(Environment.NewLine); live_1760874038_001139.m4s").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 1 [1760874038] = 1
@ -220,7 +220,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine);
start.AddSeconds(6), start.AddSeconds(6),
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input, input,
1); 1));
result.PlaylistStart.ShouldBe(start.AddSeconds(8)); result.PlaylistStart.ShouldBe(start.AddSeconds(8));
result.Sequence.ShouldBe(1139); result.Sequence.ShouldBe(1139);
@ -262,7 +262,7 @@ live_1760874042_001138.m4s
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live_1760874042_001139.m4s").Split(Environment.NewLine); live_1760874042_001139.m4s").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 1, [1760874038] = 1,
@ -273,7 +273,7 @@ live_1760874042_001139.m4s").Split(Environment.NewLine);
start.AddSeconds(6), start.AddSeconds(6),
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input, input,
maybeMaxSegments: 10); maybeMaxSegments: 10));
result.PlaylistStart.ShouldBe(start); result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(1137); result.Sequence.ShouldBe(1137);
@ -564,7 +564,7 @@ live_1760874138_000081.m4s
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:26.007-0500 #EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:26.007-0500
live_1760874138_000082.m4s").Split(Environment.NewLine); live_1760874138_000082.m4s").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 1, [1760874038] = 1,
@ -575,7 +575,7 @@ live_1760874138_000082.m4s").Split(Environment.NewLine);
start.AddSeconds(220), start.AddSeconds(220),
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input, input,
maybeMaxSegments: 10); maybeMaxSegments: 10));
// result.PlaylistStart.ShouldBe(start); // result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(56); result.Sequence.ShouldBe(56);
@ -648,7 +648,7 @@ live_1760874040_000048.m4s
// filter 'live000046.m4s' // filter 'live000046.m4s'
var filterBefore = start.AddSeconds(2); var filterBefore = start.AddSeconds(2);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 2, [1760874038] = 2,
@ -659,7 +659,7 @@ live_1760874040_000048.m4s
filterBefore, filterBefore,
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input, input,
2); 2));
result.Sequence.ShouldBe(47); result.Sequence.ShouldBe(47);
@ -782,7 +782,7 @@ live_1760874038_000064.m4s
// filter 'live000046.m4s' // filter 'live000046.m4s'
var filterBefore = start.AddSeconds(32); var filterBefore = start.AddSeconds(32);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 2 [1760874038] = 2
@ -792,7 +792,7 @@ live_1760874038_000064.m4s
filterBefore, filterBefore,
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input, input,
10); 10));
result.Sequence.ShouldBe(46); result.Sequence.ShouldBe(46);
@ -939,7 +939,7 @@ live_1760874038_000064.m4s
// filter 'live000046.m4s' // filter 'live000046.m4s'
var filterBefore = start.AddSeconds(36); var filterBefore = start.AddSeconds(36);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 2 [1760874038] = 2
@ -949,7 +949,7 @@ live_1760874038_000064.m4s
filterBefore, filterBefore,
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input, input,
10); 10));
result.Sequence.ShouldBe(47); result.Sequence.ShouldBe(47);
@ -1021,7 +1021,7 @@ live_1760874038_001138.m4s
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live_1760874038_001139.m4s").Split(Environment.NewLine); live_1760874038_001139.m4s").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( TrimPlaylistResult result = Unwrap(_hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
new Dictionary<long, int> new Dictionary<long, int>
{ {
[1760874038] = 1 [1760874038] = 1
@ -1030,12 +1030,43 @@ live_1760874038_001139.m4s").Split(Environment.NewLine);
start, start,
start.AddSeconds(60), start.AddSeconds(60),
new FakeHlsInitSegmentCache(), new FakeHlsInitSegmentCache(),
input); input));
result.SegmentCount.ShouldBeGreaterThan(0); result.SegmentCount.ShouldBeGreaterThan(0);
result.Playlist.ShouldContain("#EXTINF"); 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<TrimPlaylistResult> result = _hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int>(),
OutputFormatKind.HlsMp4,
start,
start.AddSeconds(-30),
new FakeHlsInitSegmentCache(),
input,
maybeMaxSegments: 10);
result.IsNone.ShouldBeTrue();
}
private static TrimPlaylistResult Unwrap(Option<TrimPlaylistResult> maybeResult)
{
maybeResult.IsSome.ShouldBeTrue();
return maybeResult.IfNone(() => throw new InvalidOperationException("expected trim playlist result"));
}
private static string NormalizeLineEndings(string str) => private static string NormalizeLineEndings(string str) =>
str str
.Replace("\r\n", "\n") .Replace("\r\n", "\n")

10
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

@ -8,7 +8,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 Option<TrimPlaylistResult> TrimPlaylist(
Dictionary<long, int> discontinuityMap, Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
@ -110,20 +110,18 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
File.WriteAllLines(file, lines); File.WriteAllLines(file, lines);
logger.LogError(ex, "Error filtering playlist. Bad playlist saved to {BadPlaylistFile}", file); logger.LogError(ex, "Error filtering playlist. Bad playlist saved to {BadPlaylistFile}", file);
// TODO: better error result?
return new TrimPlaylistResult(playlistStart, 0, 0, string.Empty, 0);
} }
catch catch
{ {
// do nothing // do nothing
} }
throw; // never return a fabricated empty playlist; callers must handle the failure
return Option<TrimPlaylistResult>.None;
} }
} }
public TrimPlaylistResult TrimPlaylistWithDiscontinuity( public Option<TrimPlaylistResult> TrimPlaylistWithDiscontinuity(
Dictionary<long, int> discontinuityMap, Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,

4
ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs

@ -4,7 +4,7 @@ namespace ErsatzTV.Core.FFmpeg;
public interface IHlsPlaylistFilter public interface IHlsPlaylistFilter
{ {
TrimPlaylistResult TrimPlaylist( Option<TrimPlaylistResult> TrimPlaylist(
Dictionary<long, int> discontinuityMap, Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,
@ -14,7 +14,7 @@ public interface IHlsPlaylistFilter
Option<int> maybeMaxSegments, Option<int> maybeMaxSegments,
bool endWithDiscontinuity = false); bool endWithDiscontinuity = false);
TrimPlaylistResult TrimPlaylistWithDiscontinuity( Option<TrimPlaylistResult> TrimPlaylistWithDiscontinuity(
Dictionary<long, int> discontinuityMap, Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat, OutputFormatKind outputFormat,
DateTimeOffset playlistStart, DateTimeOffset playlistStart,

Loading…
Cancel
Save