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
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>
279 lines
11 KiB
C#
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;
|
|
}
|
|
}
|