using LiveRecorder.Domain.Enums; using LiveRecorder.Application.Abstractions.Platforms; using LiveRecorder.Infrastructure.Services; using System.Diagnostics; namespace LiveRecorder.Tests; public sealed class FfmpegFailureClassificationTests { [Fact] public void FfprobeProcess_DoesNotInheritBundledCurlLibraries() { var startInfo = new ProcessStartInfo(); startInfo.Environment["LD_LIBRARY_PATH"] = "/app/runtime/lib"; FfmpegVideoMetadataService.SanitizeFfprobeProcessEnvironment(startInfo); Assert.False(startInfo.Environment.ContainsKey("LD_LIBRARY_PATH")); } [Fact] public void FfmpegHeaderFallback_ParsesValidMediaMetadata() { const string output = """ Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/records/sample.mp4': Duration: 00:02:36.64, start: 0.090000, bitrate: 2788 kb/s Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, progressive), 1088x1920, 2668 kb/s, 22 fps, 22 tbr, 90k tbn (default) Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 115 kb/s (default) Stream mapping: """; var metadata = FfmpegVideoMetadataService.ParseFfmpegHeaderOutput(output); Assert.NotNull(metadata); Assert.Equal(156.64, metadata.DurationSeconds); Assert.Equal(1088, metadata.Width); Assert.Equal(1920, metadata.Height); Assert.Equal("h264", metadata.VideoCodec); Assert.Equal("aac", metadata.AudioCodec); Assert.Equal(22, metadata.FrameRate); Assert.Equal(2_788_000, metadata.BitRate); } [Theory] [InlineData("")] [InlineData("Duration: N/A")] [InlineData("Duration: 00:00:00.00, bitrate: N/A")] public void FfmpegHeaderFallback_RejectsUnreadableMedia(string output) { Assert.Null(FfmpegVideoMetadataService.ParseFfmpegHeaderOutput(output)); } [Theory] [InlineData("pipe:0: Invalid data found when processing input")] [InlineData("Error opening input file pipe:0.")] [InlineData("Error writing trailer: Invalid argument")] [InlineData("Error muxing a packet")] [InlineData("Conversion failed!")] public void RecoverableFfmpegLines_ArePersistedAsWarnings(string line) { Assert.True(FfmpegService.IsRecoverableFfmpegWarningLine(line)); Assert.True(FfmpegService.TryClassifyPersistedFfmpegLine(line, isError: true, out var level)); Assert.Equal(SystemLogLevel.Warning, level); } [Theory] [InlineData("Error writing trailer: Invalid argument")] [InlineData("Error muxing a packet")] [InlineData("Conversion failed!")] public void MuxingFailures_TriggerRepairTranscodeFallback(string detail) { Assert.True(FfmpegService.IsRepairableMp4FinalizeError(detail)); } [Theory] [InlineData("Application provided invalid, non monotonically increasing dts to muxer in stream 1")] [InlineData("Non-monotonous DTS in output stream 0:1")] public void TimestampDiscontinuityFailures_EnableTimestampRepair(string line) { Assert.True(FfmpegService.IsTimestampDiscontinuityFailureLine(line)); } [Theory] [InlineData("Error submitting a packet to the muxer: Invalid argument")] [InlineData("Error muxing a packet")] [InlineData("Task finished with error code: -22")] public void TimestampRepairMuxerFailures_EnableTranscodeFallback(string line) { Assert.True(FfmpegService.IsTimestampMuxerFailureLine(line)); } [Theory] [InlineData(0.2, 85_000, false)] [InlineData(5, 85_000, true)] [InlineData(0.2, 1_048_576, false)] public void UnexpectedExitArtifacts_RequireMeaningfulMediaDuration( double durationSeconds, long fileSizeBytes, bool expected) { Assert.Equal( expected, FfmpegService.IsMeaningfulUnexpectedExitArtifact(durationSeconds, fileSizeBytes)); } [Theory] [InlineData(59, false)] [InlineData(60, true)] [InlineData(3600, true)] public void StableRuntime_ResetsInSessionRecoveryBudget(int seconds, bool expected) { var now = DateTimeOffset.Parse("2026-08-04T20:00:00+08:00"); Assert.Equal(expected, FfmpegService.CanResetInSessionRetryBudget(now.AddSeconds(-seconds), now)); } [Theory] [InlineData("https://example.test/live.flv", "flv", true)] [InlineData("https://example.test/live.m3u8", "hls", false)] [InlineData("https://example.test/playlist", "hls", false)] public void HttpInput_UsesCurlExceptForHls(string url, string protocol, bool expected) { Assert.Equal(expected, FfmpegService.ShouldUseCurlPipe(url, protocol)); } [Fact] public void NativeHlsInput_PreservesRequestHeaders() { var headers = new StreamInputHeaders( "RecorderTest/1.0", "https://live.example.test/room", "session=abc", new Dictionary { ["X-Test"] = "yes" }); var arguments = FfmpegService.BuildArgumentList( "https://cdn.example.test/live/index.m3u8", "/tmp/output.ts", RecordOutputFormat.Ts, RecordSaveMode.SingleFile, 1, RecordingTemplateType.StreamCopy, true, 10, 30_000_000, 30, headers, "hls", "h264", FfmpegService.FfmpegInputOptionProfile.Baseline); Assert.Contains("https://cdn.example.test/live/index.m3u8", arguments); Assert.DoesNotContain("pipe:0", arguments); Assert.Contains("-user_agent", arguments); Assert.Contains("RecorderTest/1.0", arguments); Assert.Contains("-referer", arguments); Assert.Contains(arguments, item => item.Contains("Cookie: session=abc", StringComparison.Ordinal)); Assert.Contains(arguments, item => item.Contains("X-Test: yes", StringComparison.Ordinal)); } [Fact] public void TimestampTranscodeProfile_RebuildsVideoAndAudioTimestamps() { var arguments = FfmpegService.BuildArgumentList( "https://cdn.example.test/live.flv", "/tmp/output.ts", RecordOutputFormat.Ts, RecordSaveMode.Segmented, 1, RecordingTemplateType.StreamCopy, true, 10, 30_000_000, 30, null, "flv", "h264", FfmpegService.FfmpegInputOptionProfile.TimestampTranscode); Assert.Contains("settb=AVTB,setpts=PTS-STARTPTS", arguments); Assert.Contains("libx264", arguments); Assert.Contains("aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS", arguments); Assert.Contains("make_zero", arguments); Assert.Contains("-reset_timestamps", arguments); } [Fact] public void RecoveryContext_PreservesBudgetAcrossHlsFallbackAndReachesTimestampTranscode() { var repairProfile = FfmpegService.ResolveRetryInputOptionProfile( FfmpegService.FfmpegInputOptionProfile.Baseline, hasTimestampDiscontinuityFailure: true, hasTimestampMuxerFailure: false); var repairHls = FfmpegService.AdvanceRecoveryContext( FfmpegService.InitialRecoveryContext, repairProfile, "flv", "hls"); Assert.Equal(FfmpegService.FfmpegInputOptionProfile.TimestampRepair, repairHls.InputOptionProfile); Assert.Equal(1, repairHls.AttemptCount); Assert.True(repairHls.HasRetriedWithAlternateProtocol); Assert.True(FfmpegService.ShouldImmediatelyFallbackFromHls("hls", hasHlsOverlongHeadersFailure: true)); var repairFlv = FfmpegService.AdvanceRecoveryContext( repairHls, repairHls.InputOptionProfile, "hls", "flv"); var transcodeProfile = FfmpegService.ResolveRetryInputOptionProfile( repairFlv.InputOptionProfile, hasTimestampDiscontinuityFailure: false, hasTimestampMuxerFailure: true); var transcodeFlv = FfmpegService.AdvanceRecoveryContext( repairFlv, transcodeProfile, "flv", "flv"); Assert.Equal(2, repairFlv.AttemptCount); Assert.Equal(3, transcodeFlv.AttemptCount); Assert.Equal(FfmpegService.FfmpegInputOptionProfile.TimestampTranscode, transcodeFlv.InputOptionProfile); Assert.True(transcodeFlv.HasRetriedWithAlternateProtocol); Assert.False(transcodeFlv.HasRetriedWithRefreshedStream); } [Theory] [InlineData(RecordSessionStatus.Failed, 0, true)] [InlineData(RecordSessionStatus.Failed, 59, true)] [InlineData(RecordSessionStatus.Failed, 60, false)] [InlineData(RecordSessionStatus.Completed, 1, false)] public void RuntimeFailureBackoff_UsesProcessRuntimeInsteadOfMediaDuration( RecordSessionStatus status, int processRuntimeSeconds, bool expected) { Assert.Equal( expected, FfmpegService.ShouldApplyRuntimeFailureBackoff(status, TimeSpan.FromSeconds(processRuntimeSeconds))); } [Theory] [InlineData(true, false, false, 2)] [InlineData(false, true, true, 0)] [InlineData(false, true, false, 3)] public void DeploymentShutdown_ClassifiesFinalizedAndRecoverableArtifactsWithoutFalseFailures( bool mediaValid, bool hasFinalizationError, bool hasRecoverableIntermediateOutput, int expected) { var disposition = FfmpegService.ClassifyExitedRecording( shutdownRequested: true, stopRequested: false, finalizationPaused: false, hasFinalizationError, mediaValid, exitCode: 0, completionRequested: true, hasUsableOutput: true, hasRecoverableIntermediateOutput); Assert.Equal((ExitedRecordingDisposition)expected, disposition); } [Fact] public void DeploymentShutdown_InterruptedFinalizationRemainsRecoverable() { var disposition = FfmpegService.ClassifyExitedRecording( shutdownRequested: true, stopRequested: false, finalizationPaused: true, hasFinalizationError: true, mediaValid: false, exitCode: 0, completionRequested: true, hasUsableOutput: true, hasRecoverableIntermediateOutput: true); Assert.Equal(ExitedRecordingDisposition.Processing, disposition); } [Theory] [InlineData("Unknown encoder h264_nvenc")] [InlineData("Cannot load libcuda.so.1")] [InlineData("No VA display found for device /dev/dri/renderD128")] [InlineData("Error initializing an internal MFX session")] [InlineData("Impossible to convert between the formats supported by the filter")] public void HardwareEncoderFailures_TriggerSoftwareFallback(string line) { Assert.True(FfmpegService.IsHardwareEncoderFailureLine(line)); } [Fact] public void HardwareRecoveryEncoders_UseExpectedCodecAndDeviceArguments() { var nvenc = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Nvenc, null); var qsv = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, "/dev/dri/renderD128"); var vaapi = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Vaapi, "/dev/dri/renderD129"); var nvencArguments = FfmpegService.BuildRecoveryEncoderProbeArgumentList(nvenc); var qsvArguments = FfmpegService.BuildRecoveryEncoderProbeArgumentList(qsv); var vaapiArguments = FfmpegService.BuildRecoveryEncoderProbeArgumentList(vaapi); Assert.Contains("h264_nvenc", nvencArguments); Assert.Contains("h264_qsv", qsvArguments); Assert.Contains("-qsv_device", qsvArguments); Assert.Contains("/dev/dri/renderD128", qsvArguments); Assert.Contains("h264_vaapi", vaapiArguments); Assert.Contains("-vaapi_device", vaapiArguments); Assert.Contains("/dev/dri/renderD129", vaapiArguments); } [Fact] public void TimestampTranscode_WithQsv_PlacesDeviceBeforeInputAndKeepsTimestampFilters() { var arguments = FfmpegService.BuildArgumentList( "https://cdn.example.test/live.flv", "/tmp/output.ts", RecordOutputFormat.Ts, RecordSaveMode.Segmented, 1, RecordingTemplateType.StreamCopy, true, 10, 30_000_000, 30, null, "flv", "h264", FfmpegService.FfmpegInputOptionProfile.TimestampTranscode, new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, "/dev/dri/renderD128")); var argumentList = arguments.ToList(); Assert.True(argumentList.IndexOf("-qsv_device") < argumentList.IndexOf("-i")); Assert.Contains("h264_qsv", arguments); Assert.Contains(arguments, item => item.Contains("setpts=PTS-STARTPTS", StringComparison.Ordinal)); Assert.Contains(arguments, item => item.Contains("hwupload", StringComparison.Ordinal)); Assert.Contains("aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS", arguments); } }