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] 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(