Files
ersatztv/ErsatzTV.Tests/Application/Streaming/GetPlayoutItemProcessByChannelNumberHandlerTests.cs
T
timothyandClaude Opus 4.8 dc5ceb5a14
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / decisions.md append-only (pull_request) Failing after 12m6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(473): bound the drain, correct the interface contract, quiet graceful cancels
Second review pass returned BLOCKED on two findings introduced by the
first fix commit. Both were right.

BLOCKER 1 — the drain added for "return the connection to the pool" was
unbounded. `response.Content.ReadAsByteArrayAsync()` buffers the WHOLE
body, and it ran for every non-404 response. A server that ignores
`Range: bytes=0-0` answers 200 with the entire file, so this would
download at line rate into a byte[] on the streaming hot path for up to
the 2s timeout -- strictly worse than the aborted socket it replaced, and
it defeated the ResponseHeadersRead the probe deliberately uses. Now the
single byte is read only on 206 (where the server honoured the range and
the body really is one byte); any other status aborts the socket, which
is much the cheaper evil. Two tests pin both directions; verified
non-vacuous (restoring the unbounded drain fails the 200-with-body test).

BLOCKER 2 — IRemoteStreamProber's doc-comment still described pre-fix
behaviour. I had told the reviewer it was updated; it was not -- only the
implementation's <remarks> had been. It claimed `false` on any 404 (now
only a redirected one) and that every other outcome returns `true` (caller
cancellation throws). Both clauses corrected, and the throwing contract is
now documented with <exception>.

Also fixed the reviewer's own follow-on finding: the cancellation rethrow
it asked for reached HlsSessionWorker's catch-all, which logs a
channel-level ERROR with a stack trace. The graceful
TaskCanceledException/OperationCanceledException handler at :662 wraps only
the inner ffmpeg block, not the mediator sends, so every client disconnect
on a remote-streaming channel would have produced a spurious ERROR -- in
exactly the logs a #350 cold-start investigation reads. Added a
cancellation filter on the outer try that logs Information instead.

Nit: stale SeedAll doc-comment now mentions the emby case.

Deferred, per reviewer's explicit agreement: Plex-branch handler coverage
(follow-up), and HEAD-with-GET-fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:47:50 +02:00

279 lines
11 KiB
C#

using System.IO.Abstractions;
using CliWrap;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.Core;
using NSubstitute.Extensions;
using NUnit.Framework;
using Shouldly;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Tests.Application.Streaming;
/// <summary>
/// ersatztv#473: a media-server item that is missing from disk used to fall back to the
/// remote-stream URL unconditionally. It must now be probed first, and an unavailable
/// remote stream must render an error card instead of a real playout process.
/// </summary>
[TestFixture]
public class GetPlayoutItemProcessByChannelNumberHandlerTests
{
private const string ChannelNumber = "1";
private const string JellyfinItemId = "abc123";
private const string EmbyItemId = "def456";
private readonly List<string> _tempFiles = [];
private InMemoryTvContext _db = null!;
private IFFmpegProcessService _ffmpegProcessService = null!;
private IRemoteStreamProber _remoteStreamProber = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_remoteStreamProber = Substitute.For<IRemoteStreamProber>();
_ffmpegProcessService = Substitute.For<IFFmpegProcessService>();
// both ForError and ForPlayoutItem must hand back a real value; the handler dereferences them
_ffmpegProcessService.ReturnsForAll(Task.FromResult(Cli.Wrap("ffmpeg")));
_ffmpegProcessService.ReturnsForAll(
Task.FromResult(
new PlayoutItemResult(
Cli.Wrap("ffmpeg"),
Option<GraphicsEngineContext>.None,
Option<int>.None)));
}
[TearDown]
public async Task TearDown()
{
await _db.DisposeAsync();
foreach (string tempFile in _tempFiles)
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Test]
public async Task Should_Render_Error_Card_When_Remote_Stream_Is_Unavailable()
{
DateTimeOffset now = await SeedAll();
_remoteStreamProber.IsAvailable(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(false);
Either<BaseError, PlayoutItemProcessModel> result =
await CreateHandler().Handle(Request(now), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _remoteStreamProber.Received(1)
.IsAvailable($"http://localhost:{Settings.StreamingPort}/media/jellyfin/{JellyfinItemId}", Arg.Any<CancellationToken>());
List<ICall> errorCalls = CallsTo(nameof(IFFmpegProcessService.ForError));
errorCalls.Count.ShouldBe(1);
CallsTo(nameof(IFFmpegProcessService.ForPlayoutItem)).Count.ShouldBe(0);
// the `case PlayoutItemNotAvailableFromMediaServer:` arm exists to surface the real error
// text; without it the error falls to `default:` and the card says "Channel is Offline".
// Assert the message, or the case label is untested dead weight.
object?[] arguments = errorCalls[0].GetArguments();
string errorMessage = arguments.OfType<string>().Single(a => a.Contains("not available"));
errorMessage.ShouldContain($"/media/jellyfin/{JellyfinItemId}");
}
[Test]
public async Task Should_Stream_Remotely_When_Remote_Stream_Is_Available()
{
DateTimeOffset now = await SeedAll();
_remoteStreamProber.IsAvailable(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(true);
Either<BaseError, PlayoutItemProcessModel> result =
await CreateHandler().Handle(Request(now), CancellationToken.None);
result.IsRight.ShouldBeTrue();
CallsTo(nameof(IFFmpegProcessService.ForError)).Count.ShouldBe(0);
List<ICall> playoutCalls = CallsTo(nameof(IFFmpegProcessService.ForPlayoutItem));
playoutCalls.Count.ShouldBe(1);
// videoPath is the 7th parameter of ForPlayoutItem
object?[] arguments = playoutCalls[0].GetArguments();
arguments[6].ShouldBe($"http://localhost:{Settings.StreamingPort}/media/jellyfin/{JellyfinItemId}");
}
// the fix changed all three remote-stream branches; Jellyfin above covers one, this pins that a
// second provider is probed too rather than the fix being Jellyfin-only (ersatztv#473 review)
[Test]
public async Task Should_Render_Error_Card_When_Emby_Remote_Stream_Is_Unavailable()
{
DateTimeOffset now = await SeedAll(emby: true);
_remoteStreamProber.IsAvailable(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(false);
Either<BaseError, PlayoutItemProcessModel> result =
await CreateHandler().Handle(Request(now), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _remoteStreamProber.Received(1)
.IsAvailable(
$"http://localhost:{Settings.StreamingPort}/media/emby/{EmbyItemId}",
Arg.Any<CancellationToken>());
CallsTo(nameof(IFFmpegProcessService.ForError)).Count.ShouldBe(1);
CallsTo(nameof(IFFmpegProcessService.ForPlayoutItem)).Count.ShouldBe(0);
}
private List<ICall> CallsTo(string methodName) =>
_ffmpegProcessService.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == methodName)
.ToList();
private static GetPlayoutItemProcessByChannelNumber Request(DateTimeOffset now) =>
new(
ChannelNumber,
StreamingMode.TransportStream,
now,
StartAtZero: false,
HlsRealtime: true,
ChannelStart: now,
PtsOffset: TimeSpan.Zero,
TargetFramerate: Option<FrameRate>.None,
IsTroubleshooting: false,
FFmpegProfileId: Option<int>.None);
private GetPlayoutItemProcessByChannelNumberHandler CreateHandler()
{
var fileSystem = Substitute.For<IFileSystem>();
fileSystem.File.Exists(Arg.Any<string>()).Returns(false);
return new GetPlayoutItemProcessByChannelNumberHandler(
_db.Factory,
_ffmpegProcessService,
fileSystem,
Substitute.For<IExternalJsonPlayoutItemProvider>(),
Substitute.For<IPlexPathReplacementService>(),
Substitute.For<IJellyfinPathReplacementService>(),
Substitute.For<IEmbyPathReplacementService>(),
Substitute.For<IMediaCollectionRepository>(),
Substitute.For<ITelevisionRepository>(),
Substitute.For<IArtistRepository>(),
Substitute.For<ISongVideoGenerator>(),
Substitute.For<IMusicVideoCreditsGenerator>(),
Substitute.For<IWatermarkSelector>(),
Substitute.For<IGraphicsElementSelector>(),
Substitute.For<IDecoSelector>(),
_remoteStreamProber,
NullLogger<GetPlayoutItemProcessByChannelNumberHandler>.Instance);
}
/// <summary>
/// Seeds ffmpeg/ffprobe config, an ffmpeg profile, a channel, a playout and a playout item
/// covering "now" whose media item is a jellyfin episode missing from disk — or, when
/// <paramref name="emby" /> is set, an emby episode.
/// </summary>
/// <returns>The "now" the request should use.</returns>
private async Task<DateTimeOffset> SeedAll(bool emby = false)
{
string ffmpeg = Path.GetTempFileName();
string ffprobe = Path.GetTempFileName();
_tempFiles.Add(ffmpeg);
_tempFiles.Add(ffprobe);
var now = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.FFmpegPath.Key, Value = ffmpeg });
context.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.FFprobePath.Key, Value = ffprobe });
var profile = new FFmpegProfile
{
Name = "Test",
Resolution = new Resolution { Name = "1080p", Width = 1920, Height = 1080 }
};
context.FFmpegProfiles.Add(profile);
await context.SaveChangesAsync();
var channel = new DomainChannel(Guid.NewGuid())
{
Number = ChannelNumber,
Name = "Test",
Group = "ErsatzTV",
Categories = string.Empty,
FFmpegProfileId = profile.Id,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
StreamingMode = StreamingMode.TransportStream,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous
};
context.Channels.Add(channel);
List<EpisodeMetadata> metadata =
[new EpisodeMetadata { Title = "Missing", SortTitle = "Missing", Subtitles = [] }];
List<MediaVersion> versions =
[
new MediaVersion
{
Name = "Main",
Duration = TimeSpan.FromMinutes(30),
MediaFiles = [new MediaFile { Path = "/gone/episode.mkv", PathHash = "gone" }],
Streams = []
}
];
Episode episode = emby
? new EmbyEpisode { ItemId = EmbyItemId, EpisodeMetadata = metadata, MediaVersions = versions }
: new JellyfinEpisode { ItemId = JellyfinItemId, EpisodeMetadata = metadata, MediaVersions = versions };
context.AddRange(episode);
await context.SaveChangesAsync();
var playout = new Playout
{
ChannelId = channel.Id,
ScheduleKind = PlayoutScheduleKind.Classic
};
context.Playouts.Add(playout);
await context.SaveChangesAsync();
context.PlayoutItems.Add(
new PlayoutItem
{
PlayoutId = playout.Id,
MediaItemId = episode.Id,
Start = now.AddMinutes(-5).UtcDateTime,
Finish = now.AddMinutes(25).UtcDateTime,
InPoint = TimeSpan.FromMinutes(5),
OutPoint = TimeSpan.FromMinutes(30),
Watermarks = [],
PlayoutItemWatermarks = [],
GraphicsElements = [],
PlayoutItemGraphicsElements = []
});
await context.SaveChangesAsync();
return now;
}
}