Browse Source

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 <noreply@anthropic.com>
pull/2963/head
Ministorm3 4 days ago
parent
commit
05c2025e19
  1. 2
      CHANGELOG.md
  2. 86
      ErsatzTV.Tests/Controllers/IptvControllerTests.cs
  3. 1
      ErsatzTV.Tests/ErsatzTV.Tests.csproj
  4. 19
      ErsatzTV/Controllers/IptvController.cs

2
CHANGELOG.md

@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

86
ErsatzTV.Tests/Controllers/IptvControllerTests.cs

@ -0,0 +1,86 @@ @@ -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<ContentResult>();
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<TrimPlaylistResult>.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<StatusCodeResult>();
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<IFFmpegSegmenterService>();
segmenterService.TryGetWorker(Arg.Any<string>(), out Arg.Any<IHlsSessionWorker>()).Returns(false);
IActionResult result = await MakeController(segmenterService)
.GetLivePlaylist(ChannelNumber, CancellationToken.None);
result.ShouldBeOfType<RedirectToActionResult>()
.ActionName.ShouldBe(nameof(IptvController.GetHttpLiveStreamingVideo));
}
private static IFFmpegSegmenterService WorkerReturning(Option<TrimPlaylistResult> trimResult)
{
var worker = Substitute.For<IHlsSessionWorker>();
worker.TrimPlaylist(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()).Returns(trimResult);
var segmenterService = Substitute.For<IFFmpegSegmenterService>();
segmenterService.TryGetWorker(Arg.Any<string>(), out Arg.Any<IHlsSessionWorker>())
.Returns(call =>
{
call[1] = worker;
return true;
});
return segmenterService;
}
private static IptvController MakeController(IFFmpegSegmenterService segmenterService) =>
new(
Substitute.For<IMediator>(),
Substitute.For<IGraphicsEngine>(),
NullLogger<IptvController>.Instance,
segmenterService)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
}

1
ErsatzTV.Tests/ErsatzTV.Tests.csproj

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="NSubstitute" Version="6.0.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit.Analyzers" Version="4.14.0">
<PrivateAssets>all</PrivateAssets>

19
ErsatzTV/Controllers/IptvController.cs

@ -170,9 +170,22 @@ public class IptvController : StreamingControllerBase @@ -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(

Loading…
Cancel
Save