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(