Browse Source

Merge ff3a1d3386 into 15db3c4ca0

pull/2963/merge
Ministorm3 3 days ago committed by GitHub
parent
commit
e60624f2d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 26
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  3. 71
      ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs
  4. 10
      ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs
  5. 4
      ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs
  6. 86
      ErsatzTV.Tests/Controllers/IptvControllerTests.cs
  7. 1
      ErsatzTV.Tests/ErsatzTV.Tests.csproj
  8. 19
      ErsatzTV/Controllers/IptvController.cs

5
CHANGELOG.md

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

26
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -125,7 +125,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -125,7 +125,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{
await RefreshInits();
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
Option<TrimPlaylistResult> maybeTrimResult = _hlsPlaylistFilter.TrimPlaylist(
_discontinuityMap,
_outputFormatKind,
PlaylistStart,
@ -133,13 +133,16 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -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 @@ -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<TrimPlaylistResult> 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

71
ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs

@ -42,7 +42,7 @@ live_1760874038_001138.m4s @@ -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<long, int>
{
[1760874038] = 1
@ -52,7 +52,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1
@ -109,7 +109,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1
@ -162,7 +162,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1
@ -220,7 +220,7 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1,
@ -273,7 +273,7 @@ live_1760874042_001139.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 1,
@ -575,7 +575,7 @@ live_1760874138_000082.m4s").Split(Environment.NewLine); @@ -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 @@ -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<long, int>
{
[1760874038] = 2,
@ -659,7 +659,7 @@ live_1760874040_000048.m4s @@ -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 @@ -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<long, int>
{
[1760874038] = 2
@ -792,7 +792,7 @@ live_1760874038_000064.m4s @@ -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 @@ -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<long, int>
{
[1760874038] = 2
@ -949,7 +949,7 @@ live_1760874038_000064.m4s @@ -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 @@ -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<long, int>
{
[1760874038] = 1
@ -1030,12 +1030,43 @@ live_1760874038_001139.m4s").Split(Environment.NewLine); @@ -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<TrimPlaylistResult> result = _hlsPlaylistFilter.TrimPlaylist(
new Dictionary<long, int>(),
OutputFormatKind.HlsMp4,
start,
start.AddSeconds(-30),
new FakeHlsInitSegmentCache(),
input,
maybeMaxSegments: 10);
result.IsNone.ShouldBeTrue();
}
private static TrimPlaylistResult Unwrap(Option<TrimPlaylistResult> 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")

10
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

@ -8,7 +8,7 @@ namespace ErsatzTV.Core.FFmpeg; @@ -8,7 +8,7 @@ namespace ErsatzTV.Core.FFmpeg;
public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFilter> logger) : IHlsPlaylistFilter
{
public TrimPlaylistResult TrimPlaylist(
public Option<TrimPlaylistResult> TrimPlaylist(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,
@ -110,20 +110,18 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi @@ -110,20 +110,18 @@ public class HlsPlaylistFilter(ITempFilePool tempFilePool, ILogger<HlsPlaylistFi
File.WriteAllLines(file, lines);
logger.LogError(ex, "Error filtering playlist. Bad playlist saved to {BadPlaylistFile}", file);
// TODO: better error result?
return new TrimPlaylistResult(playlistStart, 0, 0, string.Empty, 0);
}
catch
{
// do nothing
}
throw;
// never return a fabricated empty playlist; callers must handle the failure
return Option<TrimPlaylistResult>.None;
}
}
public TrimPlaylistResult TrimPlaylistWithDiscontinuity(
public Option<TrimPlaylistResult> TrimPlaylistWithDiscontinuity(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,

4
ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs

@ -4,7 +4,7 @@ namespace ErsatzTV.Core.FFmpeg; @@ -4,7 +4,7 @@ namespace ErsatzTV.Core.FFmpeg;
public interface IHlsPlaylistFilter
{
TrimPlaylistResult TrimPlaylist(
Option<TrimPlaylistResult> TrimPlaylist(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,
@ -14,7 +14,7 @@ public interface IHlsPlaylistFilter @@ -14,7 +14,7 @@ public interface IHlsPlaylistFilter
Option<int> maybeMaxSegments,
bool endWithDiscontinuity = false);
TrimPlaylistResult TrimPlaylistWithDiscontinuity(
Option<TrimPlaylistResult> TrimPlaylistWithDiscontinuity(
Dictionary<long, int> discontinuityMap,
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,

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