diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e9ded1e..3861cf445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ 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 +- 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, 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(