From 05c2025e1971d78052c3631bddb2b3bdba6db7f3 Mon Sep 17 00:00:00 2001 From: Ministorm3 <4474921+Ministorm3@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:05:37 -0400 Subject: [PATCH 1/2] fix: ask clients to retry when live playlist is unavailable This branch is only reached when a session worker exists, so a failed trim is transient: either ffmpeg has not written the first playlist yet, or the read failed. Returning 404 told clients the stream was gone, and many abandon it rather than polling again. Return 503 with a Retry-After hint instead. Cancellation now logs at debug rather than warning, since the client has already disconnected and this endpoint is polled continuously by every viewer. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + .../Controllers/IptvControllerTests.cs | 86 +++++++++++++++++++ ErsatzTV.Tests/ErsatzTV.Tests.csproj | 1 + ErsatzTV/Controllers/IptvController.cs | 19 +++- 4 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 ErsatzTV.Tests/Controllers/IptvControllerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e9ded1e..9ed3c6e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Fix regression from `v26.2.0` that caused channel logo watermarks to be ignored when the logo is a url - 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 ## [26.7.0] - 2026-07-27 ### Added diff --git a/ErsatzTV.Tests/Controllers/IptvControllerTests.cs b/ErsatzTV.Tests/Controllers/IptvControllerTests.cs new file mode 100644 index 000000000..172f4eb1d --- /dev/null +++ b/ErsatzTV.Tests/Controllers/IptvControllerTests.cs @@ -0,0 +1,86 @@ +using ErsatzTV.Controllers; +using ErsatzTV.Core.FFmpeg; +using LanguageExt; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Streaming; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class IptvControllerTests +{ + private const string ChannelNumber = "1"; + + [Test] + public async Task GetLivePlaylist_Should_Return_Playlist_When_Trim_Succeeds() + { + var trimResult = new TrimPlaylistResult(DateTimeOffset.Now, 0, 0, "#EXTM3U", 1); + IptvController controller = MakeController(WorkerReturning(trimResult)); + + IActionResult result = await controller.GetLivePlaylist(ChannelNumber, CancellationToken.None); + + var content = result.ShouldBeOfType(); + content.Content.ShouldBe("#EXTM3U"); + content.ContentType.ShouldBe("application/vnd.apple.mpegurl"); + } + + [Test] + public async Task GetLivePlaylist_Should_Ask_Client_To_Retry_When_Trim_Fails() + { + IptvController controller = MakeController(WorkerReturning(Option.None)); + + IActionResult result = await controller.GetLivePlaylist(ChannelNumber, CancellationToken.None); + + // the session worker exists, so the failure is transient; a 404 would tell clients the + // stream is gone and many would abandon it instead of polling again + var statusCode = result.ShouldBeOfType(); + statusCode.StatusCode.ShouldBe(StatusCodes.Status503ServiceUnavailable); + controller.Response.Headers.RetryAfter.ToString().ShouldBe("1"); + } + + [Test] + public async Task GetLivePlaylist_Should_Redirect_To_Start_Session_When_No_Worker() + { + var segmenterService = Substitute.For(); + segmenterService.TryGetWorker(Arg.Any(), out Arg.Any()).Returns(false); + + IActionResult result = await MakeController(segmenterService) + .GetLivePlaylist(ChannelNumber, CancellationToken.None); + + result.ShouldBeOfType() + .ActionName.ShouldBe(nameof(IptvController.GetHttpLiveStreamingVideo)); + } + + private static IFFmpegSegmenterService WorkerReturning(Option trimResult) + { + var worker = Substitute.For(); + worker.TrimPlaylist(Arg.Any(), Arg.Any()).Returns(trimResult); + + var segmenterService = Substitute.For(); + segmenterService.TryGetWorker(Arg.Any(), out Arg.Any()) + .Returns(call => + { + call[1] = worker; + return true; + }); + + return segmenterService; + } + + private static IptvController MakeController(IFFmpegSegmenterService segmenterService) => + new( + Substitute.For(), + Substitute.For(), + NullLogger.Instance, + segmenterService) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; +} diff --git a/ErsatzTV.Tests/ErsatzTV.Tests.csproj b/ErsatzTV.Tests/ErsatzTV.Tests.csproj index c7c4506c2..b233f5333 100644 --- a/ErsatzTV.Tests/ErsatzTV.Tests.csproj +++ b/ErsatzTV.Tests/ErsatzTV.Tests.csproj @@ -13,6 +13,7 @@ + all diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index bafd14ec4..c4823a8dd 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -170,9 +170,22 @@ public class IptvController : StreamingControllerBase return Content(result.Playlist, "application/vnd.apple.mpegurl"); } - // TODO: better error here? - _logger.LogWarning("Trim playlist failure; will return not found for channel {Channel}", channelNumber); - return NotFound(); + // the session worker exists, so this is transient: either ffmpeg hasn't written the + // first playlist yet, or the read failed. 404 tells clients the stream is gone and + // many will abandon it, so ask them to retry shortly instead. + if (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("Trim playlist was canceled for channel {Channel}", channelNumber); + } + else + { + _logger.LogWarning( + "Trim playlist failure for channel {Channel}; will ask client to retry", + channelNumber); + } + + Response.Headers.RetryAfter = "1"; + return StatusCode(StatusCodes.Status503ServiceUnavailable); } _logger.LogWarning( 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 2/2] 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,