diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e3bac785..4a329693c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix Trakt list sync - Fix some cases of QSV audio/video desync when *not* seeking by using software decode - This only applies to content that *might* be problematic (using a heuristic) +- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8 ## [26.2.0] - 2026-02-02 ### Added diff --git a/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs b/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs index 7f644ae06..34e6aed7c 100644 --- a/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs +++ b/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs @@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler( foreach (string ffmpegPath in maybeFFmpegPath) { - _ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath); + _ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken); Option maybeFFprobePath = await dbContext.ConfigElements .GetValue(ConfigElementKey.FFprobePath, cancellationToken) diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetSupportedHardwareAccelerationKindsHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Queries/GetSupportedHardwareAccelerationKindsHandler.cs index 32eedcbd0..a3893fea3 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Queries/GetSupportedHardwareAccelerationKindsHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Queries/GetSupportedHardwareAccelerationKindsHandler.cs @@ -31,15 +31,18 @@ public class Validation validation = await Validate(dbContext, cancellationToken); return await validation.Match( - GetHardwareAccelerationKinds, + ffmpegPath => GetHardwareAccelerationKinds(ffmpegPath, cancellationToken), _ => Task.FromResult(new List { HardwareAccelerationKind.None })); } - private async Task> GetHardwareAccelerationKinds(string ffmpegPath) + private async Task> GetHardwareAccelerationKinds( + string ffmpegPath, + CancellationToken cancellationToken) { var result = new List { HardwareAccelerationKind.None }; - IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath); + IFFmpegCapabilities ffmpegCapabilities = + await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken); if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc)) { diff --git a/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs b/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs index d0097ca2b..e6575f45b 100644 --- a/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs +++ b/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs @@ -74,7 +74,8 @@ public class ffmpegPath, originalPath, withExtension, - request.MaxHeight.Value); + request.MaxHeight.Value, + cancellationToken); CommandResult resize = await process.ExecuteAsync(cancellationToken); diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs index 15676d376..5e0c590cc 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs @@ -36,7 +36,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler validation = await Validate(dbContext, cancellationToken); return await validation.Match( - ffmpegPath => GetProcess(request, ffmpegPath), + ffmpegPath => GetProcess(request, ffmpegPath, cancellationToken), error => Task.FromResult>(error.Join())); } private async Task> GetProcess( GetSeekTextSubtitleProcess request, - string ffmpegPath) + string ffmpegPath, + CancellationToken cancellationToken) { Command process = await ffmpegProcessService.SeekTextSubtitle( ffmpegPath, request.PathAndCodec.Path, request.PathAndCodec.Codec, - request.Seek); + request.Seek, + cancellationToken); return new SeekTextSubtitleProcess(process); } diff --git a/ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs index f53b2e948..6fdd0a4ff 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs @@ -34,7 +34,8 @@ public class GetSlugProcessByChannelNumberHandler( request.Now, duration, request.HlsRealtime, - request.PtsOffset); + request.PtsOffset, + cancellationToken); var result = new PlayoutItemProcessModel( playoutItemResult, diff --git a/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs index 63a93a451..c9cec698d 100644 --- a/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs @@ -161,7 +161,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler qsvExtraHardwareFrames) + Option qsvExtraHardwareFrames, + CancellationToken cancellationToken) { FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings( channel.StreamingMode, @@ -830,7 +832,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService ? FileSystemLayout.TranscodeTroubleshootingFolder : FileSystemLayout.FFmpegReportsFolder, FileSystemLayout.FontsCacheFolder, - ffmpegPath); + ffmpegPath, + cancellationToken); FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState); @@ -843,7 +846,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService DateTimeOffset now, TimeSpan duration, bool hlsRealtime, - TimeSpan ptsOffset) + TimeSpan ptsOffset, + CancellationToken cancellationToken) { FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings( channel.StreamingMode, @@ -960,7 +964,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService ? FileSystemLayout.TranscodeTroubleshootingFolder : FileSystemLayout.FFmpegReportsFolder, FileSystemLayout.FontsCacheFolder, - ffmpegPath); + ffmpegPath, + cancellationToken); FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState); @@ -972,7 +977,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService bool saveReports, Channel channel, string scheme, - string host) + string host, + CancellationToken cancellationToken) { var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height); @@ -993,7 +999,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Option.None, FileSystemLayout.FFmpegReportsFolder, FileSystemLayout.FontsCacheFolder, - ffmpegPath); + ffmpegPath, + cancellationToken); FFmpegPipeline pipeline = pipelineBuilder.Concat( concatInputFile, @@ -1063,7 +1070,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Option.None, FileSystemLayout.FFmpegReportsFolder, FileSystemLayout.FontsCacheFolder, - ffmpegPath); + ffmpegPath, + cancellationToken); FFmpegPipeline pipeline = pipelineBuilder.WrapSegmenter( concatInputFile, @@ -1072,7 +1080,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline); } - public async Task ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height) + public async Task ResizeImage( + string ffmpegPath, + string inputFile, + string outputFile, + int height, + CancellationToken cancellationToken) { var videoInputFile = new VideoInputFile( inputFile, @@ -1105,7 +1118,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Option.None, FileSystemLayout.FFmpegReportsFolder, FileSystemLayout.FontsCacheFolder, - ffmpegPath); + ffmpegPath, + cancellationToken); FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height)); @@ -1141,7 +1155,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService watermarkWidthPercent, cancellationToken); - public async Task SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek) + public async Task SeekTextSubtitle( + string ffmpegPath, + string inputFile, + string codec, + TimeSpan seek, + CancellationToken cancellationToken) { var videoInputFile = new VideoInputFile( inputFile, @@ -1174,7 +1193,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Option.None, FileSystemLayout.FFmpegReportsFolder, FileSystemLayout.FontsCacheFolder, - ffmpegPath); + ffmpegPath, + cancellationToken); FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, codec, seek); diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index 1a4114869..7bebb6ba4 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -56,7 +56,8 @@ public interface IFFmpegProcessService string vaapiDisplay, VaapiDriver vaapiDriver, string vaapiDevice, - Option qsvExtraHardwareFrames); + Option qsvExtraHardwareFrames, + CancellationToken cancellationToken); Task Slug( string ffmpegPath, @@ -64,9 +65,16 @@ public interface IFFmpegProcessService DateTimeOffset now, TimeSpan duration, bool hlsRealtime, - TimeSpan ptsOffset); + TimeSpan ptsOffset, + CancellationToken cancellationToken); - Task ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host); + Task ConcatChannel( + string ffmpegPath, + bool saveReports, + Channel channel, + string scheme, + string host, + CancellationToken cancellationToken); Task WrapSegmenter( string ffmpegPath, @@ -77,7 +85,12 @@ public interface IFFmpegProcessService string accessToken, CancellationToken cancellationToken); - Task ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height); + Task ResizeImage( + string ffmpegPath, + string inputFile, + string outputFile, + int height, + CancellationToken cancellationToken); Task> GenerateSongImage( string ffmpegPath, @@ -94,5 +107,10 @@ public interface IFFmpegProcessService int watermarkWidthPercent, CancellationToken cancellationToken); - Task SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek); + Task SeekTextSubtitle( + string ffmpegPath, + string inputFile, + string codec, + TimeSpan seek, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index 5afb1a770..64e485b4e 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -122,7 +122,7 @@ public class PipelineBuilderBaseTests string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result); command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -progress - -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); + "-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); } [Test] @@ -225,7 +225,7 @@ public class PipelineBuilderBaseTests string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result); command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -progress - -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); + "-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); } [Test] @@ -390,7 +390,7 @@ public class PipelineBuilderBaseTests // 0.4.0 reference: "-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -ss 00:14:33.6195516 -i /tmp/whatever.mkv -map 0:0 -map 0:a -c:v copy -flags cgop -sc_threshold 0 -c:a copy -movflags +faststart -metadata service_provider="ErsatzTV" -metadata service_name="ErsatzTV" -t 00:06:39.6934484 -f mpegts -mpegts_flags +initial_discontinuity pipe:1" command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1"); + "-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1"); } [Test] @@ -484,7 +484,7 @@ public class PipelineBuilderBaseTests string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result); command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1"); + "-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1"); } [Test] @@ -556,6 +556,7 @@ public class PipelineBuilderBaseTests } public class DefaultFFmpegCapabilities() : FFmpegCapabilities( + string.Empty, new System.Collections.Generic.HashSet(), new System.Collections.Generic.HashSet(), new System.Collections.Generic.HashSet(), diff --git a/ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs b/ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs index 92ed9521c..9fa18ee11 100644 --- a/ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs +++ b/ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs @@ -4,6 +4,7 @@ using ErsatzTV.FFmpeg.Format; namespace ErsatzTV.FFmpeg.Capabilities; public class FFmpegCapabilities( + string ffmpegVersion, IReadOnlySet ffmpegHardwareAccelerations, IReadOnlySet ffmpegDecoders, IReadOnlySet ffmpegFilters, @@ -12,6 +13,8 @@ public class FFmpegCapabilities( IReadOnlySet ffmpegDemuxFormats) : IFFmpegCapabilities { + public string Version => ffmpegVersion; + public bool HasHardwareAcceleration(HardwareAccelerationMode hardwareAccelerationMode) { // AMF isn't a "hwaccel" in ffmpeg, so check for presence of encoders diff --git a/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs b/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs index 777e8d8a9..1bb27b9ec 100644 --- a/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs +++ b/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs @@ -25,6 +25,7 @@ public partial class HardwareCapabilitiesFactory( : IHardwareCapabilitiesFactory { private const string CudaDeviceKey = "ffmpeg.hardware.cuda.device"; + private const string FFmpegVersionKey = "ffmpeg.version"; private static readonly CompositeFormat VaapiCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.vaapi.{0}.{1}.{2}"); @@ -46,6 +47,7 @@ public partial class HardwareCapabilitiesFactory( public void ClearCache() { + memoryCache.Remove(FFmpegVersionKey); memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "hwaccels")); memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "decoders")); memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "filters")); @@ -54,33 +56,39 @@ public partial class HardwareCapabilitiesFactory( memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "formats")); } - public async Task GetFFmpegCapabilities(string ffmpegPath) + public async Task GetFFmpegCapabilities(string ffmpegPath, CancellationToken cancellationToken) { // TODO: validate videotoolbox somehow // TODO: validate amf somehow + string ffmpegVersion = await GetFFmpegVersion(ffmpegPath, cancellationToken); + IReadOnlySet ffmpegHardwareAccelerations = - await GetFFmpegCapabilities(ffmpegPath, "hwaccels", ParseFFmpegAccelLine) + await GetFFmpegCapabilities(ffmpegPath, "hwaccels", ParseFFmpegAccelLine, cancellationToken) .Map(set => set.Intersect(FFmpegKnownHardwareAcceleration.AllAccels).ToImmutableHashSet()); - IReadOnlySet ffmpegDecoders = await GetFFmpegCapabilities(ffmpegPath, "decoders", ParseFFmpegLine) - .Map(set => set.Intersect(FFmpegKnownDecoder.AllDecoders).ToImmutableHashSet()); + IReadOnlySet ffmpegDecoders = + await GetFFmpegCapabilities(ffmpegPath, "decoders", ParseFFmpegLine, cancellationToken) + .Map(set => set.Intersect(FFmpegKnownDecoder.AllDecoders).ToImmutableHashSet()); IEnumerable allFilterNames = FFmpegKnownFilter.AllFilters.Union(FFmpegKnownFilter.RequiredFilters.Select(f => f.Name)); - IReadOnlySet ffmpegFilters = await GetFFmpegCapabilities(ffmpegPath, "filters", ParseFFmpegLine) - .Map(set => set.Intersect(allFilterNames).ToImmutableHashSet()); + IReadOnlySet ffmpegFilters = + await GetFFmpegCapabilities(ffmpegPath, "filters", ParseFFmpegLine, cancellationToken) + .Map(set => set.Intersect(allFilterNames).ToImmutableHashSet()); - IReadOnlySet ffmpegEncoders = await GetFFmpegCapabilities(ffmpegPath, "encoders", ParseFFmpegLine) - .Map(set => set.Intersect(FFmpegKnownEncoder.AllEncoders).ToImmutableHashSet()); + IReadOnlySet ffmpegEncoders = + await GetFFmpegCapabilities(ffmpegPath, "encoders", ParseFFmpegLine, cancellationToken) + .Map(set => set.Intersect(FFmpegKnownEncoder.AllEncoders).ToImmutableHashSet()); - IReadOnlySet ffmpegOptions = await GetFFmpegOptions(ffmpegPath) + IReadOnlySet ffmpegOptions = await GetFFmpegOptions(ffmpegPath, cancellationToken) .Map(set => set.Intersect(FFmpegKnownOption.AllOptions).ToImmutableHashSet()); - IReadOnlySet ffmpegDemuxFormats = await GetFFmpegFormats(ffmpegPath, "D") + IReadOnlySet ffmpegDemuxFormats = await GetFFmpegFormats(ffmpegPath, "D", cancellationToken) .Map(set => set.Intersect(FFmpegKnownFormat.AllFormats).ToImmutableHashSet()); return new FFmpegCapabilities( + ffmpegVersion, ffmpegHardwareAccelerations, ffmpegDecoders, ffmpegFilters, @@ -339,10 +347,40 @@ public partial class HardwareCapabilitiesFactory( return memoryCache.TryGetValue(cacheKey, out bool installed) && installed; } + public async Task GetFFmpegVersion(string ffmpegPath, CancellationToken cancellationToken) + { + if (memoryCache.TryGetValue(FFmpegVersionKey, out string? ffmpegVersion) && + ffmpegVersion is not null) + { + return ffmpegVersion; + } + + string[] arguments = ["-version"]; + + BufferedCommandResult result = await Cli.Wrap(ffmpegPath) + .WithArguments(arguments) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(Encoding.UTF8, cancellationToken); + + string output = string.IsNullOrWhiteSpace(result.StandardOutput) + ? result.StandardError + : result.StandardOutput; + + string versionResult = await output.Split("\n").Map(s => s.Trim()) + .Bind(l => ParseFFmpegVersionLine(l)) + .HeadOrNone() + .IfNoneAsync(string.Empty); + + memoryCache.Set(FFmpegVersionKey, versionResult); + + return versionResult; + } + private async Task> GetFFmpegCapabilities( string ffmpegPath, string capabilities, - Func> parseLine) + Func> parseLine, + CancellationToken cancellationToken) { var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, capabilities); if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && @@ -356,7 +394,7 @@ public partial class HardwareCapabilitiesFactory( BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(Encoding.UTF8); + .ExecuteBufferedAsync(Encoding.UTF8, cancellationToken); string output = string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardError @@ -371,7 +409,7 @@ public partial class HardwareCapabilitiesFactory( return capabilitiesResult; } - private async Task> GetFFmpegOptions(string ffmpegPath) + private async Task> GetFFmpegOptions(string ffmpegPath, CancellationToken cancellationToken) { var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "options"); if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && @@ -385,7 +423,7 @@ public partial class HardwareCapabilitiesFactory( BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(Encoding.UTF8); + .ExecuteBufferedAsync(Encoding.UTF8, cancellationToken); string output = string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardError @@ -400,7 +438,10 @@ public partial class HardwareCapabilitiesFactory( return capabilitiesResult; } - private async Task> GetFFmpegFormats(string ffmpegPath, string muxDemux) + private async Task> GetFFmpegFormats( + string ffmpegPath, + string muxDemux, + CancellationToken cancellationToken) { var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "formats"); if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && @@ -414,7 +455,7 @@ public partial class HardwareCapabilitiesFactory( BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(Encoding.UTF8); + .ExecuteBufferedAsync(Encoding.UTF8, cancellationToken); string output = string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardError @@ -431,6 +472,12 @@ public partial class HardwareCapabilitiesFactory( return capabilitiesResult; } + private static Option ParseFFmpegVersionLine(string input) + { + Match match = VersionRegex().Match(input); + return match.Success ? match.Groups[1].Value : Option.None; + } + private static Option ParseFFmpegAccelLine(string input) { Match match = AccelRegex().Match(input); @@ -661,6 +708,9 @@ public partial class HardwareCapabilitiesFactory( return new NoHardwareCapabilities(); } + [GeneratedRegex(@"version\s+([^\s]+)")] + private static partial Regex VersionRegex(); + [GeneratedRegex(@"^([\w]+)$")] private static partial Regex AccelRegex(); diff --git a/ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs b/ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs index 460eeeb30..3dcfc5fec 100644 --- a/ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs +++ b/ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs @@ -4,6 +4,7 @@ namespace ErsatzTV.FFmpeg.Capabilities; public interface IFFmpegCapabilities { + string Version { get; } bool HasHardwareAcceleration(HardwareAccelerationMode hardwareAccelerationMode); bool HasDecoder(FFmpegKnownDecoder decoder); bool HasEncoder(FFmpegKnownEncoder encoder); diff --git a/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs b/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs index c7aef1a67..1908e0565 100644 --- a/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs +++ b/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs @@ -6,7 +6,9 @@ public interface IHardwareCapabilitiesFactory { void ClearCache(); - Task GetFFmpegCapabilities(string ffmpegPath); + Task GetFFmpegCapabilities(string ffmpegPath, CancellationToken cancellationToken); + + Task GetFFmpegVersion(string ffmpegPath, CancellationToken cancellationToken); Task GetHardwareCapabilities( IFFmpegCapabilities ffmpegCapabilities, diff --git a/ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs b/ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs index 2f9c9bf22..f8376ae56 100644 --- a/ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs +++ b/ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs @@ -57,6 +57,18 @@ public class NvidiaHardwareCapabilities(CudaDevice cudaDevice, IFFmpegCapabiliti isHardware = false; } + // 10-bit h264 hardware decode is not supported until ffmpeg 8 + if (isHardware && videoFormat is VideoFormat.H264 && bitDepth == 10) + { + bool isVersion8OrNewer = int.TryParse( + ffmpegCapabilities.Version.Split('.')[0].TrimStart('n', 'N'), + out int major) && major >= 8; + if (!isVersion8OrNewer) + { + isHardware = false; + } + } + if (isHardware) { return videoFormat switch diff --git a/ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs b/ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs index e203e4966..e827e8b61 100644 --- a/ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs +++ b/ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs @@ -15,5 +15,6 @@ public interface IPipelineBuilderFactory Option vaapiDevice, string reportsFolder, string fontsFolder, - string ffmpegPath); + string ffmpegPath, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index eeb638ed7..30779e663 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -211,6 +211,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder new NoStandardInputOption(), new HideBannerOption(), new NoStatsOption(), + new ProgressOption(), new LoglevelErrorOption(), new StandardFormatFlags(), new NoDemuxDecodeDelayOutputOption(), @@ -220,7 +221,6 @@ public abstract class PipelineBuilderBase : IPipelineBuilder if (desiredState.VideoFormat != VideoFormat.Copy) { pipelineSteps.Add(new ClosedGopOutputOption()); - pipelineSteps.Add(new ProgressOption()); } if (desiredState.VideoFormat != VideoFormat.Copy && !desiredState.AllowBFrames) diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs index 5cf0257fd..9bcd02639 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs @@ -29,9 +29,11 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory Option vaapiDevice, string reportsFolder, string fontsFolder, - string ffmpegPath) + string ffmpegPath, + CancellationToken cancellationToken) { - IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath); + IFFmpegCapabilities ffmpegCapabilities = + await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken); IHardwareCapabilities capabilities = await _hardwareCapabilitiesFactory.GetHardwareCapabilities( ffmpegCapabilities, diff --git a/ErsatzTV.Infrastructure/Health/Checks/FFmpegCapabilitiesHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/FFmpegCapabilitiesHealthCheck.cs index 6191f15cc..83f9dc26a 100644 --- a/ErsatzTV.Infrastructure/Health/Checks/FFmpegCapabilitiesHealthCheck.cs +++ b/ErsatzTV.Infrastructure/Health/Checks/FFmpegCapabilitiesHealthCheck.cs @@ -23,7 +23,8 @@ public class FFmpegCapabilitiesHealthCheck(IConfigElementRepository configElemen foreach (ConfigElement ffmpegPath in maybeFFmpegPath) { - var ffmpegCapabilities = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value); + var ffmpegCapabilities = + await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value, cancellationToken); List missingFilters = []; diff --git a/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs index cfdf14c37..f0f663f72 100644 --- a/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs +++ b/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs @@ -1,20 +1,20 @@ -using System.Text.RegularExpressions; -using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Health; using ErsatzTV.Core.Health.Checks; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.FFmpeg.Capabilities; namespace ErsatzTV.Infrastructure.Health.Checks; -public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository) +public class FFmpegVersionHealthCheck( + IConfigElementRepository configElementRepository, + IHardwareCapabilitiesFactory hardwareCapabilitiesFactory) : BaseHealthCheck, IFFmpegVersionHealthCheck { private const string BundledVersion = "7.1.1"; private const string BundledVersionVaapi = "7.1.1"; private const string WindowsVersionPrefix = "n7.1.1"; - private static readonly string[] FFmpegVersionArguments = { "-version" }; - public override string Title => "FFmpeg Version"; public async Task Check(CancellationToken cancellationToken) @@ -37,8 +37,9 @@ public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepo foreach (ConfigElement ffmpegPath in maybeFFmpegPath) { - Option maybeVersion = await GetVersion(ffmpegPath.Value, cancellationToken); - if (maybeVersion.IsNone) + Option maybeVersion = + await hardwareCapabilitiesFactory.GetFFmpegVersion(ffmpegPath.Value, cancellationToken); + if (maybeVersion.IsNone || maybeVersion.Exists(string.IsNullOrWhiteSpace)) { return WarningResult("Unable to determine ffmpeg version", "Unable to determine ffmpeg version", link); } @@ -54,8 +55,9 @@ public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepo foreach (ConfigElement ffprobePath in maybeFFprobePath) { - Option maybeVersion = await GetVersion(ffprobePath.Value, cancellationToken); - if (maybeVersion.IsNone) + Option maybeVersion = + await hardwareCapabilitiesFactory.GetFFmpegVersion(ffprobePath.Value, cancellationToken); + if (maybeVersion.IsNone || maybeVersion.Exists(string.IsNullOrWhiteSpace)) { return WarningResult( "Unable to determine ffprobe version", @@ -101,21 +103,4 @@ public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepo return None; } - - private static async Task> GetVersion(string path, CancellationToken cancellationToken) - { - Option maybeLine = await GetProcessOutput(path, FFmpegVersionArguments, cancellationToken) - .Map(s => s.Split("\n").HeadOrNone().Map(h => h.Trim())); - foreach (string line in maybeLine) - { - const string PATTERN = @"version\s+([^\s]+)"; - Match match = Regex.Match(line, PATTERN); - if (match.Success) - { - return match.Groups[1].Value; - } - } - - return None; - } } diff --git a/ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs index 615ddbeb2..178a449fe 100644 --- a/ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs +++ b/ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs @@ -55,7 +55,7 @@ public class VaapiDriverHealthCheck( foreach (string ffmpegPath in maybeFFmpegPath) { IFFmpegCapabilities ffmpegCapabilities = - await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath); + await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken); foreach (FFmpegProfile profile in activeFFmpegProfiles) { Option vaapiDriver = VaapiDriverName(profile.VaapiDriver); diff --git a/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs b/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs index 918415e07..3b156663c 100644 --- a/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs +++ b/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs @@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler( foreach (string ffmpegPath in maybeFFmpegPath) { - _ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath); + _ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken); Option maybeFFprobePath = await dbContext.ConfigElements .GetValue(ConfigElementKey.FFprobePath, cancellationToken)