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; } }