using ErsatzTV.Application.FFmpegProfiles; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.FFmpeg; using ErsatzTV.FFmpeg.OutputFormat; using Microsoft.EntityFrameworkCore; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using LanguageExt; using NSubstitute; using NUnit.Framework; using Shouldly; using static LanguageExt.Prelude; using Unit = LanguageExt.Unit; namespace ErsatzTV.Tests.Application.FFmpegProfiles; [TestFixture] public class FFmpegProfileHandlerTests { private InMemoryTvContext _db = null!; private IConfigElementRepository _configElementRepository = null!; private ISearchTargets _searchTargets = null!; [SetUp] public async Task SetUp() { _db = await InMemoryTvContext.CreateAsync(); _configElementRepository = Substitute.For(); _configElementRepository.GetValue(Arg.Any(), Arg.Any()) .Returns(Option.None); _searchTargets = Substitute.For(); } [TearDown] public async Task TearDown() => await _db.DisposeAsync(); [Test] public async Task Create_Should_Return_NotFoundError_When_Resolution_Missing() { var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle(MakeCreate(resolutionId: 999), CancellationToken.None); LeftOf(result).ShouldBeOfType(); } [Test] public async Task Update_Should_Return_NotFoundError_When_Profile_Missing() { var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle(MakeUpdate(999), CancellationToken.None); LeftOf(result).ShouldBeOfType(); } [Test] public async Task Update_Should_Return_NotFoundError_When_Resolution_Missing() { await SeedProfile(1); var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle(MakeUpdate(1, resolutionId: 999), CancellationToken.None); LeftOf(result).ShouldBeOfType(); } [Test] public async Task Delete_Should_Return_NotFoundError_When_Profile_Missing() { var handler = new DeleteFFmpegProfileHandler(_db.Factory, _configElementRepository, _searchTargets); Either result = await handler.Handle(new DeleteFFmpegProfile(999), CancellationToken.None); LeftOf(result).ShouldBeOfType(); } [Test] public async Task Update_Should_Reject_Null_Name() { // Issue #172 FIX A: the handler guarded a client-nullable Name with a bare Name.Length // check, so a null name threw a NullReferenceException (HTTP 500). The guard now returns a // validation failure (Left) instead of throwing. await SeedProfile(1); await SeedResolution(1); var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); // Resolution seeded so the resolution-exists gate passes and the null name is the sole // failure: the guard must return a Left (validation failure) rather than throwing an NRE. Either result = await handler.Handle(MakeUpdate(1) with { Name = null! }, CancellationToken.None); LeftOf(result).ShouldBeAssignableTo(); } [Test] public async Task Create_Should_Persist_QsvPreferNativeDecoder_False() { // Guards the EF nullable-bool gotcha: QsvPreferNativeDecoder is `bool?` on the domain // entity with null-means-ON semantics, so an explicit `false` must not get coerced back to // null/true anywhere between the command and the persisted row. await SeedResolution(1); var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle(MakeCreate(1, qsvPreferNativeDecoder: false), CancellationToken.None); CreateFFmpegProfileResult created = RightOf(result); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId); persisted.QsvPreferNativeDecoder.ShouldBe(false); } [Test] public async Task Update_Should_Persist_QsvPreferNativeDecoder_False() { await SeedProfile(1); await SeedResolution(1); var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle(MakeUpdate(1, qsvPreferNativeDecoder: false), CancellationToken.None); RightOf(result); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1); persisted.QsvPreferNativeDecoder.ShouldBe(false); } // ersatztv#735: the write path used to accept an out-of-range pool size and store the floored // value instead, so a client that PUT 0 got a 200 and read back 64. it is now rejected, naming // the bound; FFmpegState still floors at render time for rows that predate this. [TestCase(0)] [TestCase(-8)] [TestCase(63)] public async Task Create_Should_Reject_QsvExtraHardwareFrames_Below_Minimum(int configured) { await SeedResolution(1); var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeCreate(1, qsvExtraHardwareFrames: configured), CancellationToken.None); LeftOf(result).Value.ShouldContain("at least 64"); await using TvContext context = _db.CreateContext(); (await context.FFmpegProfiles.CountAsync()).ShouldBe(0); } [TestCase(64)] [TestCase(128)] public async Task Create_Should_Store_QsvExtraHardwareFrames_Exactly_As_Submitted(int configured) { await SeedResolution(1); var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeCreate(1, qsvExtraHardwareFrames: configured), CancellationToken.None); CreateFFmpegProfileResult created = RightOf(result); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId); persisted.QsvExtraHardwareFrames.ShouldBe(configured); } [TestCase(0)] [TestCase(-8)] [TestCase(63)] public async Task Update_Should_Reject_A_Newly_Submitted_QsvExtraHardwareFrames_Below_Minimum(int configured) { await SeedProfile(1, qsvExtraHardwareFrames: 128); await SeedResolution(1); var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeUpdate(1, qsvExtraHardwareFrames: configured), CancellationToken.None); LeftOf(result).Value.ShouldContain("at least 64"); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1); persisted.QsvExtraHardwareFrames.ShouldBe(128); } // the other half of the same rule: the SPA sends the whole profile back on every edit, so a row // stored before this validation existed must stay editable over fields the operator did touch. // an UNCHANGED out-of-range value is written back as-is and floored at render time instead [Test] public async Task Update_Should_Accept_An_Unchanged_Legacy_QsvExtraHardwareFrames() { await SeedProfile(1, qsvExtraHardwareFrames: 0); await SeedResolution(1); var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeUpdate(1, qsvExtraHardwareFrames: 0), CancellationToken.None); RightOf(result); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1); persisted.QsvExtraHardwareFrames.ShouldBe(0); new FFmpegState( false, HardwareAccelerationMode.None, HardwareAccelerationMode.None, None, None, None, None, false, None, None, None, None, None, OutputFormatKind.MpegTs, None, None, None, None, TimeSpan.Zero, None, Optional(persisted.QsvExtraHardwareFrames), false, false, "linear", false) .QsvExtraHardwareFrames.ShouldBe(FFmpegState.MinimumQsvExtraHardwareFrames); } // null means "unconfigured" and FFmpegState already resolves it to the same 64; it must stay // null rather than being silently materialized into a stored value [Test] public async Task Create_Should_Leave_Null_QsvExtraHardwareFrames_Null() { await SeedResolution(1); var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle(MakeCreate(1), CancellationToken.None); CreateFFmpegProfileResult created = RightOf(result); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId); persisted.QsvExtraHardwareFrames.ShouldBeNull(); } // ersatztv#735: readrate pacing is an operator-tunable bounded field. out of band it is a dead // channel either way — below realtime the client starves, above the ceiling the input is no // longer meaningfully paced (which is the unthrottled read #529 measured to write no segments) [TestCase(0.9)] [TestCase(0.0)] [TestCase(2.5)] public async Task Create_Should_Reject_ReadRate_Outside_Bounds(double configured) { await SeedResolution(1); var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeCreate(1, readRate: configured), CancellationToken.None); LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0"); } [TestCase(0.9)] [TestCase(10.5)] public async Task Create_Should_Reject_ReadRateCatchup_Outside_Bounds(double configured) { await SeedResolution(1); var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeCreate(1, readRateCatchup: configured), CancellationToken.None); LeftOf(result).Value.ShouldContain("Read rate catchup must be between 1.0 and 10.0"); } // a catchup rate inside its own band can still be at or below the base rate, where it cannot // let a lagging input recover — the cross-field bound is the one a per-field check cannot see. // the EQUAL cases matter: zero headroom is functionally no catchup, while still reading as a // configured one [TestCase(null, 1.0)] [TestCase(null, 1.05)] [TestCase(1.5, 1.2)] [TestCase(1.5, 1.5)] [TestCase(2.0, 2.0)] public async Task Create_Should_Reject_ReadRateCatchup_At_Or_Below_The_ReadRate(double? readRate, double catchup) { await SeedResolution(1); var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeCreate(1, readRate: readRate, readRateCatchup: catchup), CancellationToken.None); LeftOf(result).Value.ShouldContain("must be greater than the read rate"); } [Test] public async Task Create_Should_Persist_ReadRate_Pacing() { await SeedResolution(1); var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeCreate(1, readRate: 1.2, readRateCatchup: 4.0), CancellationToken.None); CreateFFmpegProfileResult created = RightOf(result); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId); persisted.ReadRate.ShouldBe(1.2); persisted.ReadRateCatchup.ShouldBe(4.0); } // unset is the default posture and must stay null: FFmpegState resolves null to the values the // pipeline used before the fields existed, so an untouched profile paces exactly as it did [Test] public async Task Create_Should_Leave_Unset_ReadRate_Pacing_Null() { await SeedResolution(1); var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle(MakeCreate(1), CancellationToken.None); CreateFFmpegProfileResult created = RightOf(result); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId); persisted.ReadRate.ShouldBeNull(); persisted.ReadRateCatchup.ShouldBeNull(); } [Test] public async Task Update_Should_Reject_ReadRate_Outside_Bounds() { await SeedProfile(1); await SeedResolution(1); var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeUpdate(1, readRate: 3.0), CancellationToken.None); LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0"); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1); persisted.ReadRate.ShouldBeNull(); } [Test] public async Task Update_Should_Persist_ReadRate_Pacing() { await SeedProfile(1); await SeedResolution(1); var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); Either result = await handler.Handle( MakeUpdate(1, readRate: 1.5, readRateCatchup: 8.0), CancellationToken.None); RightOf(result); await using TvContext context = _db.CreateContext(); FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1); persisted.ReadRate.ShouldBe(1.5); persisted.ReadRateCatchup.ShouldBe(8.0); } private static TR RightOf(Either either) => either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e}"), Right: r => r); private static BaseError LeftOf(Either either) => either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); private async Task SeedResolution(int id) { await using TvContext context = _db.CreateContext(); context.Resolutions.Add(new Resolution { Id = id, Name = "1920x1080", Width = 1920, Height = 1080 }); await context.SaveChangesAsync(); } private async Task SeedProfile(int id, int? qsvExtraHardwareFrames = null) { await using TvContext context = _db.CreateContext(); context.FFmpegProfiles.Add(new FFmpegProfile { Id = id, QsvExtraHardwareFrames = qsvExtraHardwareFrames, Name = "Default", ThreadCount = 1, NormalizeAudio = true, NormalizeVideo = true, HardwareAcceleration = HardwareAccelerationKind.None, VaapiDisplay = "drm", VaapiDriver = VaapiDriver.Default, VaapiDevice = "/dev/dri/renderD128", ResolutionId = 1, ScalingBehavior = ScalingBehavior.ScaleAndPad, PadMode = FilterMode.Software, VideoFormat = FFmpegProfileVideoFormat.H264, VideoProfile = string.Empty, VideoPreset = string.Empty, BitDepth = FFmpegProfileBitDepth.EightBit, VideoBitrate = 2_000, VideoBufferSize = 4_000, TonemapAlgorithm = FFmpegProfileTonemapAlgorithm.Linear, AudioFormat = FFmpegProfileAudioFormat.Aac, AudioBitrate = 192, AudioBufferSize = 384, NormalizeLoudnessMode = NormalizeLoudnessMode.Off, AudioChannels = 2, AudioSampleRate = 48_000, NormalizeFramerate = false, NormalizeColors = false, DeinterlaceVideo = false }); await context.SaveChangesAsync(); } private static CreateFFmpegProfile MakeCreate( int resolutionId, bool qsvPreferNativeDecoder = true, int? qsvExtraHardwareFrames = null, double? readRate = null, double? readRateCatchup = null) => new( "Default", 1, true, true, HardwareAccelerationKind.None, "drm", VaapiDriver.Default, "/dev/dri/renderD128", qsvExtraHardwareFrames, resolutionId, ScalingBehavior.ScaleAndPad, FilterMode.Software, FFmpegProfileVideoFormat.H264, string.Empty, string.Empty, false, FFmpegProfileBitDepth.EightBit, 2_000, 4_000, FFmpegProfileTonemapAlgorithm.Linear, FFmpegProfileAudioFormat.Aac, 192, 384, NormalizeLoudnessMode.Off, null, 2, 48_000, false, false, false, qsvPreferNativeDecoder, readRate, readRateCatchup); private static UpdateFFmpegProfile MakeUpdate( int id, int resolutionId = 1, bool qsvPreferNativeDecoder = true, int? qsvExtraHardwareFrames = null, double? readRate = null, double? readRateCatchup = null) => new( id, "Default", 1, true, true, HardwareAccelerationKind.None, "drm", VaapiDriver.Default, "/dev/dri/renderD128", qsvExtraHardwareFrames, resolutionId, ScalingBehavior.ScaleAndPad, FilterMode.Software, FFmpegProfileVideoFormat.H264, string.Empty, string.Empty, false, FFmpegProfileBitDepth.EightBit, 2_000, 4_000, FFmpegProfileTonemapAlgorithm.Linear, FFmpegProfileAudioFormat.Aac, 192, 384, NormalizeLoudnessMode.Off, null, 2, 48_000, false, false, false, qsvPreferNativeDecoder, readRate, readRateCatchup); }