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;
///
/// 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.
///
[TestFixture]
public class GetPlayoutItemProcessByChannelNumberHandlerTests
{
private const string ChannelNumber = "1";
private const string JellyfinItemId = "abc123";
private const string EmbyItemId = "def456";
private readonly List _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();
_ffmpegProcessService = Substitute.For();
// 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.None,
Option.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(), Arg.Any()).Returns(false);
Either 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());
List 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().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(), Arg.Any()).Returns(true);
Either result =
await CreateHandler().Handle(Request(now), CancellationToken.None);
result.IsRight.ShouldBeTrue();
CallsTo(nameof(IFFmpegProcessService.ForError)).Count.ShouldBe(0);
List 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(), Arg.Any()).Returns(false);
Either 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());
CallsTo(nameof(IFFmpegProcessService.ForError)).Count.ShouldBe(1);
CallsTo(nameof(IFFmpegProcessService.ForPlayoutItem)).Count.ShouldBe(0);
}
private List 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.None,
IsTroubleshooting: false,
FFmpegProfileId: Option.None);
private GetPlayoutItemProcessByChannelNumberHandler CreateHandler()
{
var fileSystem = Substitute.For();
fileSystem.File.Exists(Arg.Any()).Returns(false);
return new GetPlayoutItemProcessByChannelNumberHandler(
_db.Factory,
_ffmpegProcessService,
fileSystem,
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
Substitute.For(),
_remoteStreamProber,
NullLogger.Instance);
}
///
/// 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
/// is set, an emby episode.
///
/// The "now" the request should use.
private async Task 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 metadata =
[new EpisodeMetadata { Title = "Missing", SortTitle = "Missing", Subtitles = [] }];
List 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;
}
}