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. 14
      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/). @@ -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

14
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -125,7 +125,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -125,7 +125,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{
await RefreshInits();
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
Option<TrimPlaylistResult> maybeTrimResult = _hlsPlaylistFilter.TrimPlaylist(
_discontinuityMap,
_outputFormatKind,
PlaylistStart,
@ -133,13 +133,16 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -133,13 +133,16 @@ public class HlsSessionWorker : IHlsSessionWorker
_hlsInitSegmentCache,
input,
maybeMaxSegments: 10);
foreach (TrimPlaylistResult trimResult in maybeTrimResult)
{
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
{
DeleteOldSegments(trimResult);
_lastDelete = DateTimeOffset.Now;
}
}
return trimResult;
return maybeTrimResult;
}
_logger.LogWarning("HlsSessionWorker.TrimPlaylist read empty playlist?");
@ -691,13 +694,17 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -691,13 +694,17 @@ public class HlsSessionWorker : IHlsSessionWorker
await RefreshInits();
// trim playlist and insert discontinuity before appending with new ffmpeg process
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
Option<TrimPlaylistResult> maybeTrimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_discontinuityMap,
_outputFormatKind,
PlaylistStart,
DateTimeOffset.Now.AddMinutes(-1),
_hlsInitSegmentCache,
lines);
// on trim failure, leave the existing playlist untouched
foreach (TrimPlaylistResult trimResult in maybeTrimResult)
{
await WritePlaylist(trimResult.Playlist, cancellationToken);
DeleteOldSegments(trimResult);
@ -705,6 +712,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -705,6 +712,7 @@ public class HlsSessionWorker : IHlsSessionWorker
PlaylistStart = trimResult.PlaylistStart;
}
}
}
finally
{
_slim.Release();

71
ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs

@ -42,7 +42,7 @@ live_1760874038_001138.m4s @@ -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<long, int>
{
[1760874038] = 1
@ -52,7 +52,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1
@ -109,7 +109,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1
@ -162,7 +162,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1
@ -220,7 +220,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1,
@ -273,7 +273,7 @@ live_1760874042_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1,
@ -575,7 +575,7 @@ live_1760874138_000082.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 2,
@ -659,7 +659,7 @@ live_1760874040_000048.m4s @@ -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 @@ -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<long, int>
{
[1760874038] = 2
@ -792,7 +792,7 @@ live_1760874038_000064.m4s @@ -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 @@ -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<long, int>
{
[1760874038] = 2
@ -949,7 +949,7 @@ live_1760874038_000064.m4s @@ -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 @@ -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<long, int>
{
[1760874038] = 1
@ -1030,12 +1030,43 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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<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) =>
str
.Replace("\r\n", "\n")

10
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

@ -8,7 +8,7 @@ namespace ErsatzTV.Core.FFmpeg; @@ -8,7 +8,7 @@ namespace ErsatzTV.Core.FFmpeg;
public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFilter> logger) : IHlsPlaylistFilter
{
public TrimPlaylistResult TrimPlaylist(
public Option<TrimPlaylistResult> TrimPlaylist(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,
@ -110,20 +110,18 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi @@ -110,20 +110,18 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
File.WriteAllLines(file, lines);
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
{
// 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,
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,

4
ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs

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

Loading…
Cancel
Save