From 132466b3d3660b9636d6c32131cef0e87b35cffc Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:48:54 -0600 Subject: [PATCH] add avisynth script support to all local libraries (#2612) * detect avisynth demuxer * cache ffmpeg capabilities * check for working avisynth * scan avs files in all local libraries * update changelog --- CHANGELOG.md | 5 + .../ErsatzTV.Application.csproj.DotSettings | 1 + .../Commands/RefreshFFmpegCapabilities.cs | 3 + .../RefreshFFmpegCapabilitiesHandler.cs | 45 ++++ .../Commands/UpdateFFmpegSettingsHandler.cs | 59 +++-- .../Queries/GetTroubleshootingInfoHandler.cs | 9 + .../Troubleshooting/TroubleshootingInfo.cs | 2 + .../PipelineBuilderBaseTests.cs | 19 +- .../Capabilities/FFmpegCapabilities.cs | 49 ++--- .../Capabilities/FFmpegKnownFilter.cs | 9 +- .../Capabilities/FFmpegKnownFormat.cs | 19 ++ .../HardwareCapabilitiesFactory.cs | 203 ++++++++++++------ .../Capabilities/IFFmpegCapabilities.cs | 1 + .../IHardwareCapabilitiesFactory.cs | 6 + ErsatzTV.FFmpeg/Format/VideoFormat.cs | 1 + .../Metadata/LocalStatisticsProviderTests.cs | 2 + .../Metadata/LocalStatisticsProvider.cs | 10 + .../Core/FFmpeg/TranscodingTests.cs | 2 + .../Commands/RefreshFFmpegCapabilities.cs | 3 + .../RefreshFFmpegCapabilitiesHandler.cs | 45 ++++ .../Core/Metadata/LocalFolderScanner.cs | 2 +- .../ErsatzTV.Scanner.csproj.DotSettings | 1 + ErsatzTV.Scanner/Program.cs | 2 + ErsatzTV.Scanner/Worker.cs | 5 + ErsatzTV/ErsatzTV.csproj | 1 + .../Troubleshooting/Troubleshooting.razor | 5 + ErsatzTV/Resources/test.avs | 2 + .../RunOnce/ResourceExtractorService.cs | 1 + ErsatzTV/Services/SchedulerService.cs | 5 + ErsatzTV/Services/WorkerService.cs | 4 + 30 files changed, 378 insertions(+), 143 deletions(-) create mode 100644 ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs create mode 100644 ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs create mode 100644 ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFormat.cs create mode 100644 ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs create mode 100644 ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs create mode 100644 ErsatzTV/Resources/test.avs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3dc90d7..9f4ab92b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Scripts live in config / scripts / mpegts - Each script gets its own subfolder which contains an `mpegts.yml` definition and corresponding windows (powershell) and linux (bash) scripts - The global MPEG-TS script can be configured in **Settings** > **FFmpeg** > **Default MPEG-TS Script** +- Add `.avs` AviSynth Script support to all local libraries + - `.avs` was added as a valid extension, so they should behave the same any other video file + - There are two requirements for AviSynth Scripts to work: + - FFmpeg needs to be compiled with AviSynth support (not currently available in Docker) + - AviSynth itself needs to be installed ### Fixed - Fix HLS Direct playback with Jellyfin 10.11 diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings b/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings index c80a049df..e810bb164 100644 --- a/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings +++ b/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings @@ -9,6 +9,7 @@ True True True + True True True True diff --git a/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs b/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs new file mode 100644 index 000000000..27fbdc33e --- /dev/null +++ b/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.FFmpeg; + +public record RefreshFFmpegCapabilities : IRequest, IBackgroundServiceRequest; diff --git a/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs b/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs new file mode 100644 index 000000000..7f644ae06 --- /dev/null +++ b/ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs @@ -0,0 +1,45 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.FFmpeg.Capabilities; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.FFmpeg; + +public class RefreshFFmpegCapabilitiesHandler( + IDbContextFactory dbContextFactory, + IHardwareCapabilitiesFactory hardwareCapabilitiesFactory, + ILocalStatisticsProvider localStatisticsProvider) + : IRequestHandler +{ + public async Task Handle(RefreshFFmpegCapabilities request, CancellationToken cancellationToken) + { + hardwareCapabilitiesFactory.ClearCache(); + + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybeFFmpegPath = await dbContext.ConfigElements + .GetValue(ConfigElementKey.FFmpegPath, cancellationToken) + .FilterT(File.Exists); + + foreach (string ffmpegPath in maybeFFmpegPath) + { + _ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath); + + Option maybeFFprobePath = await dbContext.ConfigElements + .GetValue(ConfigElementKey.FFprobePath, cancellationToken) + .FilterT(File.Exists); + + foreach (string ffprobePath in maybeFFprobePath) + { + Either result = await localStatisticsProvider.GetStatistics( + ffprobePath, + Path.Combine(FileSystemLayout.ResourcesCacheFolder, "test.avs")); + + hardwareCapabilitiesFactory.SetAviSynthInstalled(result.IsRight); + } + } + } +} diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs index fc40c28c1..e49eac0bc 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Globalization; using System.Threading.Channels; +using ErsatzTV.Application.FFmpeg; using ErsatzTV.Application.Subtitles; using ErsatzTV.Core; using ErsatzTV.Core.Domain; @@ -9,22 +10,12 @@ using ErsatzTV.Core.Interfaces.Repositories; namespace ErsatzTV.Application.FFmpegProfiles; -public class UpdateFFmpegSettingsHandler : IRequestHandler> +public class UpdateFFmpegSettingsHandler( + IConfigElementRepository configElementRepository, + ILocalFileSystem localFileSystem, + ChannelWriter workerChannel) + : IRequestHandler> { - private readonly IConfigElementRepository _configElementRepository; - private readonly ILocalFileSystem _localFileSystem; - private readonly ChannelWriter _workerChannel; - - public UpdateFFmpegSettingsHandler( - IConfigElementRepository configElementRepository, - ILocalFileSystem localFileSystem, - ChannelWriter workerChannel) - { - _configElementRepository = configElementRepository; - _localFileSystem = localFileSystem; - _workerChannel = workerChannel; - } - public Task> Handle( UpdateFFmpegSettings request, CancellationToken cancellationToken) => @@ -44,7 +35,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler> ValidateToolPath(string path, string name) { - if (!_localFileSystem.FileExists(path)) + if (!localFileSystem.FileExists(path)) { return BaseError.New($"{name} path does not exist"); } @@ -71,21 +62,21 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler ApplyUpdate(UpdateFFmpegSettings request, CancellationToken cancellationToken) { - await _configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken); - await _configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken); - await _configElementRepository.Upsert( + await configElementRepository.Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath, cancellationToken); + await configElementRepository.Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath, cancellationToken); + await configElementRepository.Upsert( ConfigElementKey.FFmpegDefaultProfileId, request.Settings.DefaultFFmpegProfileId.ToString(CultureInfo.InvariantCulture), cancellationToken); - await _configElementRepository.Upsert( + await configElementRepository.Upsert( ConfigElementKey.FFmpegSaveReports, request.Settings.SaveReports.ToString(), cancellationToken); - await _configElementRepository.Upsert( + await configElementRepository.Upsert( ConfigElementKey.FFmpegHlsDirectOutputFormat, request.Settings.HlsDirectOutputFormat, cancellationToken); - await _configElementRepository.Upsert( + await configElementRepository.Upsert( ConfigElementKey.FFmpegDefaultMpegTsScript, request.Settings.DefaultMpegTsScript, cancellationToken); @@ -95,12 +86,12 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler.None), cancellationToken); + await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option.None), cancellationToken); } if (request.Settings.GlobalWatermarkId is not null) { - await _configElementRepository.Upsert( + await configElementRepository.Upsert( ConfigElementKey.FFmpegGlobalWatermarkId, request.Settings.GlobalWatermarkId.Value, cancellationToken); } else { - await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken); + await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken); } if (request.Settings.GlobalFallbackFillerId is not null) { - await _configElementRepository.Upsert( + await configElementRepository.Upsert( ConfigElementKey.FFmpegGlobalFallbackFillerId, request.Settings.GlobalFallbackFillerId.Value, cancellationToken); } else { - await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken); + await configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken); } - await _configElementRepository.Upsert( + await configElementRepository.Upsert( ConfigElementKey.FFmpegSegmenterTimeout, request.Settings.HlsSegmenterIdleTimeout, cancellationToken); - await _configElementRepository.Upsert( + await configElementRepository.Upsert( ConfigElementKey.FFmpegWorkAheadSegmenters, request.Settings.WorkAheadSegmenterLimit, cancellationToken); - await _configElementRepository.Upsert( + await configElementRepository.Upsert( ConfigElementKey.FFmpegInitialSegmentCount, request.Settings.InitialSegmentCount, cancellationToken); + await workerChannel.WriteAsync(new RefreshFFmpegCapabilities(), cancellationToken); + return Unit.Default; } } diff --git a/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs index 9610b88f2..63a93a451 100644 --- a/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs @@ -73,6 +73,9 @@ public class GetTroubleshootingInfoHandler : IRequestHandler FFmpegProfiles, List Channels, List Watermarks, + bool AviSynthDemuxer, + bool AviSynthInstalled, string NvidiaCapabilities, string QsvCapabilities, string VaapiCapabilities, diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index 7032c1975..c10351fdf 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -544,16 +544,11 @@ public class PipelineBuilderBaseTests return command; } - public class DefaultFFmpegCapabilities : FFmpegCapabilities - { - public DefaultFFmpegCapabilities() - : base( - new System.Collections.Generic.HashSet(), - new System.Collections.Generic.HashSet(), - new System.Collections.Generic.HashSet(), - new System.Collections.Generic.HashSet(), - new System.Collections.Generic.HashSet()) - { - } - } + public class DefaultFFmpegCapabilities() : FFmpegCapabilities( + new System.Collections.Generic.HashSet(), + new System.Collections.Generic.HashSet(), + new System.Collections.Generic.HashSet(), + 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 308638a2a..92ed9521c 100644 --- a/ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs +++ b/ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs @@ -3,34 +3,21 @@ using ErsatzTV.FFmpeg.Format; namespace ErsatzTV.FFmpeg.Capabilities; -public class FFmpegCapabilities : IFFmpegCapabilities +public class FFmpegCapabilities( + IReadOnlySet ffmpegHardwareAccelerations, + IReadOnlySet ffmpegDecoders, + IReadOnlySet ffmpegFilters, + IReadOnlySet ffmpegEncoders, + IReadOnlySet ffmpegOptions, + IReadOnlySet ffmpegDemuxFormats) + : IFFmpegCapabilities { - private readonly IReadOnlySet _ffmpegDecoders; - private readonly IReadOnlySet _ffmpegEncoders; - private readonly IReadOnlySet _ffmpegFilters; - private readonly IReadOnlySet _ffmpegHardwareAccelerations; - private readonly IReadOnlySet _ffmpegOptions; - - public FFmpegCapabilities( - IReadOnlySet ffmpegHardwareAccelerations, - IReadOnlySet ffmpegDecoders, - IReadOnlySet ffmpegFilters, - IReadOnlySet ffmpegEncoders, - IReadOnlySet ffmpegOptions) - { - _ffmpegHardwareAccelerations = ffmpegHardwareAccelerations; - _ffmpegDecoders = ffmpegDecoders; - _ffmpegFilters = ffmpegFilters; - _ffmpegEncoders = ffmpegEncoders; - _ffmpegOptions = ffmpegOptions; - } - public bool HasHardwareAcceleration(HardwareAccelerationMode hardwareAccelerationMode) { // AMF isn't a "hwaccel" in ffmpeg, so check for presence of encoders if (hardwareAccelerationMode is HardwareAccelerationMode.Amf) { - return _ffmpegEncoders.Any(e => e.EndsWith( + return ffmpegEncoders.Any(e => e.EndsWith( $"_{FFmpegKnownHardwareAcceleration.Amf.Name}", StringComparison.OrdinalIgnoreCase)); } @@ -38,7 +25,7 @@ public class FFmpegCapabilities : IFFmpegCapabilities // V4l2m2m isn't a "hwaccel" in ffmpeg, so check for presence of encoders if (hardwareAccelerationMode is HardwareAccelerationMode.V4l2m2m) { - return _ffmpegEncoders.Any(e => e.EndsWith( + return ffmpegEncoders.Any(e => e.EndsWith( $"_{FFmpegKnownHardwareAcceleration.V4l2m2m.Name}", StringComparison.OrdinalIgnoreCase)); } @@ -57,19 +44,21 @@ public class FFmpegCapabilities : IFFmpegCapabilities foreach (FFmpegKnownHardwareAcceleration accelToCheck in maybeAccelToCheck) { - return _ffmpegHardwareAccelerations.Contains(accelToCheck.Name); + return ffmpegHardwareAccelerations.Contains(accelToCheck.Name); } return false; } - public bool HasDecoder(FFmpegKnownDecoder decoder) => _ffmpegDecoders.Contains(decoder.Name); + public bool HasDecoder(FFmpegKnownDecoder decoder) => ffmpegDecoders.Contains(decoder.Name); + + public bool HasEncoder(FFmpegKnownEncoder encoder) => ffmpegEncoders.Contains(encoder.Name); - public bool HasEncoder(FFmpegKnownEncoder encoder) => _ffmpegEncoders.Contains(encoder.Name); + public bool HasFilter(FFmpegKnownFilter filter) => ffmpegFilters.Contains(filter.Name); - public bool HasFilter(FFmpegKnownFilter filter) => _ffmpegFilters.Contains(filter.Name); + public bool HasOption(FFmpegKnownOption ffmpegOption) => ffmpegOptions.Contains(ffmpegOption.Name); - public bool HasOption(FFmpegKnownOption ffmpegOption) => _ffmpegOptions.Contains(ffmpegOption.Name); + public bool HasDemuxFormat(FFmpegKnownFormat format) => ffmpegDemuxFormats.Contains(format.Name); public Option SoftwareDecoderForVideoFormat(string videoFormat) => videoFormat switch @@ -83,9 +72,9 @@ public class FFmpegCapabilities : IFFmpegCapabilities VideoFormat.MsMpeg4V3 => new DecoderMsMpeg4V3(), VideoFormat.Mpeg4 => new DecoderMpeg4(), VideoFormat.Vp9 => new DecoderVp9(), - VideoFormat.Av1 => new DecoderAv1(_ffmpegDecoders), + VideoFormat.Av1 => new DecoderAv1(ffmpegDecoders), - VideoFormat.Raw => new DecoderRawVideo(), + VideoFormat.Raw or VideoFormat.RawVideo => new DecoderRawVideo(), VideoFormat.Undetermined => new DecoderImplicit(), VideoFormat.Copy => new DecoderImplicit(), VideoFormat.GeneratedImage => new DecoderImplicit(), diff --git a/ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFilter.cs b/ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFilter.cs index 7c08015c6..ea82e9c11 100644 --- a/ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFilter.cs +++ b/ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFilter.cs @@ -11,9 +11,8 @@ public record FFmpegKnownFilter public string Name { get; } public static IList AllFilters => - new[] - { - ScaleNpp.Name, - TonemapOpenCL.Name - }; + [ + ScaleNpp.Name, + TonemapOpenCL.Name + ]; } diff --git a/ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFormat.cs b/ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFormat.cs new file mode 100644 index 000000000..598b61362 --- /dev/null +++ b/ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFormat.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ErsatzTV.FFmpeg.Capabilities; + +[SuppressMessage("ReSharper", "IdentifierTypo")] +[SuppressMessage("ReSharper", "StringLiteralTypo")] +public record FFmpegKnownFormat +{ + public static readonly FFmpegKnownFormat AviSynth = new("avisynth"); + + private FFmpegKnownFormat(string Name) => this.Name = Name; + + public string Name { get; } + + public static IList AllFormats => + [ + AviSynth.Name + ]; +} diff --git a/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs b/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs index 01f01d8be..c0b4e4d51 100644 --- a/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs +++ b/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.InteropServices; using System.Text; @@ -17,12 +18,16 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.FFmpeg.Capabilities; -public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory +public partial class HardwareCapabilitiesFactory( + IMemoryCache memoryCache, + IRuntimeInfo runtimeInfo, + ILogger logger) + : IHardwareCapabilitiesFactory { private const string CudaDeviceKey = "ffmpeg.hardware.cuda.device"; - private static readonly CompositeFormat - VaapiCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.vaapi.{0}.{1}.{2}"); + private static readonly CompositeFormat VaapiCacheKeyFormat = + CompositeFormat.Parse("ffmpeg.hardware.vaapi.{0}.{1}.{2}"); private static readonly CompositeFormat QsvCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.qsv.{0}"); private static readonly CompositeFormat FFmpegCapabilitiesCacheKeyFormat = CompositeFormat.Parse("ffmpeg.{0}"); @@ -36,19 +41,14 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory "-f", "null", "-" }; - private readonly ILogger _logger; - - private readonly IMemoryCache _memoryCache; - private readonly IRuntimeInfo _runtimeInfo; - - public HardwareCapabilitiesFactory( - IMemoryCache memoryCache, - IRuntimeInfo runtimeInfo, - ILogger logger) + public void ClearCache() { - _memoryCache = memoryCache; - _runtimeInfo = runtimeInfo; - _logger = logger; + memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "hwaccels")); + memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "decoders")); + memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "filters")); + memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "encoders")); + memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "options")); + memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "formats")); } public async Task GetFFmpegCapabilities(string ffmpegPath) @@ -72,12 +72,16 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory IReadOnlySet ffmpegOptions = await GetFFmpegOptions(ffmpegPath) .Map(set => set.Intersect(FFmpegKnownOption.AllOptions).ToImmutableHashSet()); + IReadOnlySet ffmpegDemuxFormats = await GetFFmpegFormats(ffmpegPath, "D") + .Map(set => set.Intersect(FFmpegKnownFormat.AllFormats).ToImmutableHashSet()); + return new FFmpegCapabilities( ffmpegHardwareAccelerations, ffmpegDecoders, ffmpegFilters, ffmpegEncoders, - ffmpegOptions); + ffmpegOptions, + ffmpegDemuxFormats); } public async Task GetHardwareCapabilities( @@ -95,7 +99,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory if (!ffmpegCapabilities.HasHardwareAcceleration(hardwareAccelerationMode)) { - _logger.LogWarning( + logger.LogWarning( "FFmpeg does not support {HardwareAcceleration} acceleration; will use software mode", hardwareAccelerationMode); @@ -107,7 +111,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory HardwareAccelerationMode.Nvenc => GetNvidiaCapabilities(ffmpegCapabilities), HardwareAccelerationMode.Qsv => await GetQsvCapabilities(ffmpegPath, vaapiDevice), HardwareAccelerationMode.Vaapi => await GetVaapiCapabilities(vaapiDisplay, vaapiDriver, vaapiDevice), - HardwareAccelerationMode.VideoToolbox => new VideoToolboxHardwareCapabilities(ffmpegCapabilities, _logger), + HardwareAccelerationMode.VideoToolbox => new VideoToolboxHardwareCapabilities(ffmpegCapabilities, logger), HardwareAccelerationMode.Amf => new AmfHardwareCapabilities(), HardwareAccelerationMode.V4l2m2m => new V4l2m2mHardwareCapabilities(ffmpegCapabilities), HardwareAccelerationMode.Rkmpp => new RkmppHardwareCapabilities(), @@ -148,13 +152,13 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory // if we don't have a list of cuda devices, fall back to ffmpeg check string[] arguments = - { + [ "-f", "lavfi", "-i", "nullsrc", "-c:v", "h264_nvenc", "-gpu", "list", "-f", "null", "-" - }; + ]; BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) @@ -288,13 +292,14 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory return []; } + [SuppressMessage("ReSharper", "InconsistentNaming")] public List GetVideoToolboxDecoders() { var result = new List(); foreach (string fourCC in FourCC.AllVideoToolbox) { - if (VideoToolboxUtil.IsHardwareDecoderSupported(fourCC, _logger)) + if (VideoToolboxUtil.IsHardwareDecoderSupported(fourCC, logger)) { result.Add(fourCC); } @@ -303,7 +308,27 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory return result; } - public List GetVideoToolboxEncoders() => VideoToolboxUtil.GetAvailableEncoders(_logger); + public List GetVideoToolboxEncoders() => VideoToolboxUtil.GetAvailableEncoders(logger); + + public void SetAviSynthInstalled(bool aviSynthInstalled) + { + var cacheKey = string.Format( + CultureInfo.InvariantCulture, + FFmpegCapabilitiesCacheKeyFormat, + "avisynth_installed"); + + memoryCache.Set(cacheKey, aviSynthInstalled); + } + + public bool IsAviSynthInstalled() + { + var cacheKey = string.Format( + CultureInfo.InvariantCulture, + FFmpegCapabilitiesCacheKeyFormat, + "avisynth_installed"); + + return memoryCache.TryGetValue(cacheKey, out bool installed) && installed; + } private async Task> GetFFmpegCapabilities( string ffmpegPath, @@ -311,13 +336,13 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory Func> parseLine) { var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, capabilities); - if (_memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && + if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && cachedCapabilities is not null) { return cachedCapabilities; } - string[] arguments = { "-hide_banner", $"-{capabilities}" }; + string[] arguments = ["-hide_banner", $"-{capabilities}"]; BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) @@ -328,21 +353,25 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory ? result.StandardError : result.StandardOutput; - return output.Split("\n").Map(s => s.Trim()) + var capabilitiesResult = output.Split("\n").Map(s => s.Trim()) .Bind(l => parseLine(l)) .ToImmutableHashSet(); + + memoryCache.Set(cacheKey, capabilitiesResult); + + return capabilitiesResult; } private async Task> GetFFmpegOptions(string ffmpegPath) { var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "options"); - if (_memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && + if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && cachedCapabilities is not null) { return cachedCapabilities; } - string[] arguments = { "-hide_banner", "-h", "long" }; + string[] arguments = ["-hide_banner", "-h", "long"]; BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) @@ -353,32 +382,70 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory ? result.StandardError : result.StandardOutput; - return output.Split("\n").Map(s => s.Trim()) + var capabilitiesResult = output.Split("\n").Map(s => s.Trim()) .Bind(l => ParseFFmpegOptionLine(l)) .ToImmutableHashSet(); + + memoryCache.Set(cacheKey, capabilitiesResult); + + return capabilitiesResult; + } + + private async Task> GetFFmpegFormats(string ffmpegPath, string muxDemux) + { + var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "formats"); + if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && + cachedCapabilities is not null) + { + return cachedCapabilities; + } + + string[] arguments = ["-hide_banner", "-formats"]; + + BufferedCommandResult result = await Cli.Wrap(ffmpegPath) + .WithArguments(arguments) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(Encoding.UTF8); + + string output = string.IsNullOrWhiteSpace(result.StandardOutput) + ? result.StandardError + : result.StandardOutput; + + var capabilitiesResult = output.Split("\n").Map(s => s.Trim()) + .Bind(l => ParseFFmpegFormatLine(l)) + .Where(tuple => tuple.Item1.Contains(muxDemux)) + .Map(tuple => tuple.Item2) + .ToImmutableHashSet(); + + memoryCache.Set(cacheKey, capabilitiesResult); + + return capabilitiesResult; } private static Option ParseFFmpegAccelLine(string input) { - const string PATTERN = @"^([\w]+)$"; - Match match = Regex.Match(input, PATTERN); + Match match = AccelRegex().Match(input); return match.Success ? match.Groups[1].Value : Option.None; } private static Option ParseFFmpegLine(string input) { - const string PATTERN = @"^\s*?[A-Z\.]+\s+(\w+).*"; - Match match = Regex.Match(input, PATTERN); + Match match = FFmpegRegex().Match(input); return match.Success ? match.Groups[1].Value : Option.None; } private static Option ParseFFmpegOptionLine(string input) { - const string PATTERN = @"^-([a-z_]+)\s+.*"; - Match match = Regex.Match(input, PATTERN); + Match match = OptionRegex().Match(input); return match.Success ? match.Groups[1].Value : Option.None; } + private static Option> ParseFFmpegFormatLine(string input) + { + Match match = FormatRegex().Match(input); + return match.Success ? Tuple(match.Groups[1].Value, match.Groups[2].Value) : Option>.None; + } + private async Task GetVaapiCapabilities( Option vaapiDisplay, Option vaapiDriver, @@ -390,7 +457,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory { // this shouldn't really happen - _logger.LogError( + logger.LogError( "Cannot detect VAAPI capabilities without device {Device}", vaapiDevice); @@ -402,16 +469,16 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory string device = vaapiDevice.IfNone(string.Empty); var cacheKey = string.Format(CultureInfo.InvariantCulture, VaapiCacheKeyFormat, display, driver, device); - if (_memoryCache.TryGetValue(cacheKey, out List? profileEntrypoints) && + if (memoryCache.TryGetValue(cacheKey, out List? profileEntrypoints) && profileEntrypoints is not null) { - return new VaapiHardwareCapabilities(profileEntrypoints, _logger); + return new VaapiHardwareCapabilities(profileEntrypoints, logger); } Option output = await GetVaapiOutput(display, vaapiDriver, device); if (output.IsNone) { - _logger.LogWarning("Unable to determine VAAPI capabilities; please install vainfo"); + logger.LogWarning("Unable to determine VAAPI capabilities; please install vainfo"); return new DefaultHardwareCapabilities(); } @@ -424,7 +491,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory { if (display == "drm") { - _logger.LogDebug( + logger.LogDebug( "Detected {Count} VAAPI profile entrypoints using {Driver} {Device}", profileEntrypoints.Count, driver, @@ -432,26 +499,26 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory } else { - _logger.LogDebug( + logger.LogDebug( "Detected {Count} VAAPI profile entrypoints using {Display} {Driver}", profileEntrypoints.Count, display, driver); } - _memoryCache.Set(cacheKey, profileEntrypoints); - return new VaapiHardwareCapabilities(profileEntrypoints, _logger); + memoryCache.Set(cacheKey, profileEntrypoints); + return new VaapiHardwareCapabilities(profileEntrypoints, logger); } } catch (Exception ex) { - _logger.LogWarning( + logger.LogWarning( ex, "Error detecting VAAPI capabilities; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); } - _logger.LogWarning( + logger.LogWarning( "Error detecting VAAPI capabilities; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); @@ -461,32 +528,32 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory { try { - if (_runtimeInfo.IsOSPlatform(OSPlatform.Linux) && qsvDevice.IsNone) + if (runtimeInfo.IsOSPlatform(OSPlatform.Linux) && qsvDevice.IsNone) { // this shouldn't really happen - _logger.LogError("Cannot detect QSV capabilities without device {Device}", qsvDevice); + logger.LogError("Cannot detect QSV capabilities without device {Device}", qsvDevice); return new NoHardwareCapabilities(); } string device = qsvDevice.IfNone(string.Empty); var cacheKey = string.Format(CultureInfo.InvariantCulture, QsvCacheKeyFormat, device); - if (_memoryCache.TryGetValue(cacheKey, out List? profileEntrypoints) && + if (memoryCache.TryGetValue(cacheKey, out List? profileEntrypoints) && profileEntrypoints is not null) { - return new VaapiHardwareCapabilities(profileEntrypoints, _logger); + return new VaapiHardwareCapabilities(profileEntrypoints, logger); } QsvOutput output = await GetQsvOutput(ffmpegPath, qsvDevice); if (output.ExitCode != 0) { - _logger.LogWarning("QSV test failed; some hardware accelerated features will be unavailable"); + logger.LogWarning("QSV test failed; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); } - if (_runtimeInfo.IsOSPlatform(OSPlatform.Linux)) + if (runtimeInfo.IsOSPlatform(OSPlatform.Linux)) { - if (!_memoryCache.TryGetValue("ffmpeg.vaapi_displays", out List? vaapiDisplays)) + if (!memoryCache.TryGetValue("ffmpeg.vaapi_displays", out List? vaapiDisplays)) { vaapiDisplays = ["drm"]; } @@ -499,7 +566,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory Option vaapiOutput = await GetVaapiOutput(vaapiDisplay, Option.None, device); if (vaapiOutput.IsNone) { - _logger.LogWarning("Unable to determine QSV capabilities; please install vainfo"); + logger.LogWarning("Unable to determine QSV capabilities; please install vainfo"); return new DefaultHardwareCapabilities(); } @@ -510,13 +577,13 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory if (profileEntrypoints is not null && profileEntrypoints.Count != 0) { - _logger.LogDebug( + logger.LogDebug( "Detected {Count} VAAPI profile entrypoints using QSV device {Device}", profileEntrypoints.Count, device); - _memoryCache.Set(cacheKey, profileEntrypoints); - return new VaapiHardwareCapabilities(profileEntrypoints, _logger); + memoryCache.Set(cacheKey, profileEntrypoints); + return new VaapiHardwareCapabilities(profileEntrypoints, logger); } } } @@ -526,7 +593,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory } catch (Exception ex) { - _logger.LogWarning( + logger.LogWarning( ex, "Error detecting QSV capabilities; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); @@ -535,9 +602,9 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory private IHardwareCapabilities GetNvidiaCapabilities(IFFmpegCapabilities ffmpegCapabilities) { - if (_memoryCache.TryGetValue(CudaDeviceKey, out CudaDevice? cudaDevice) && cudaDevice is not null) + if (memoryCache.TryGetValue(CudaDeviceKey, out CudaDevice? cudaDevice) && cudaDevice is not null) { - return new NvidiaHardwareCapabilities(cudaDevice, ffmpegCapabilities, _logger); + return new NvidiaHardwareCapabilities(cudaDevice, ffmpegCapabilities, logger); } try @@ -545,14 +612,14 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory Option> maybeDevices = CudaHelper.GetDevices(); foreach (CudaDevice firstDevice in maybeDevices.Map(list => list.HeadOrNone())) { - _logger.LogDebug( + logger.LogDebug( "Detected NVIDIA GPU model {Model} architecture SM {Major}.{Minor}", firstDevice.Model, firstDevice.Version.Major, firstDevice.Version.Minor); - _memoryCache.Set(CudaDeviceKey, firstDevice); - return new NvidiaHardwareCapabilities(firstDevice, ffmpegCapabilities, _logger); + memoryCache.Set(CudaDeviceKey, firstDevice); + return new NvidiaHardwareCapabilities(firstDevice, ffmpegCapabilities, logger); } } catch (FileNotFoundException) @@ -560,9 +627,21 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory // do nothing } - _logger.LogWarning( + logger.LogWarning( "Error detecting NVIDIA GPU capabilities; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); } + + [GeneratedRegex(@"^([\w]+)$")] + private static partial Regex AccelRegex(); + + [GeneratedRegex(@"^\s*?[A-Z\.]+\s+(\w+).*")] + private static partial Regex FFmpegRegex(); + + [GeneratedRegex(@"^-([a-z_]+)\s+.*")] + private static partial Regex OptionRegex(); + + [GeneratedRegex(@"([DE]+)\s+(\w+)")] + private static partial Regex FormatRegex(); } diff --git a/ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs b/ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs index 83f659eb4..460eeeb30 100644 --- a/ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs +++ b/ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs @@ -9,5 +9,6 @@ public interface IFFmpegCapabilities bool HasEncoder(FFmpegKnownEncoder encoder); bool HasFilter(FFmpegKnownFilter filter); bool HasOption(FFmpegKnownOption ffmpegOption); + bool HasDemuxFormat(FFmpegKnownFormat format); Option SoftwareDecoderForVideoFormat(string videoFormat); } diff --git a/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs b/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs index baf6a5756..c7aef1a67 100644 --- a/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs +++ b/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs @@ -4,6 +4,8 @@ namespace ErsatzTV.FFmpeg.Capabilities; public interface IHardwareCapabilitiesFactory { + void ClearCache(); + Task GetFFmpegCapabilities(string ffmpegPath); Task GetHardwareCapabilities( @@ -29,4 +31,8 @@ public interface IHardwareCapabilitiesFactory List GetVideoToolboxDecoders(); List GetVideoToolboxEncoders(); + + void SetAviSynthInstalled(bool aviSynthInstalled); + + bool IsAviSynthInstalled(); } diff --git a/ErsatzTV.FFmpeg/Format/VideoFormat.cs b/ErsatzTV.FFmpeg/Format/VideoFormat.cs index 76b3df7ac..591b01f07 100644 --- a/ErsatzTV.FFmpeg/Format/VideoFormat.cs +++ b/ErsatzTV.FFmpeg/Format/VideoFormat.cs @@ -15,6 +15,7 @@ public static class VideoFormat public const string Av1 = "av1"; public const string MpegTs = "mpegts"; public const string Raw = "raw"; + public const string RawVideo = "rawvideo"; public const string Copy = "copy"; public const string GeneratedImage = "generated-image"; diff --git a/ErsatzTV.Infrastructure.Tests/Metadata/LocalStatisticsProviderTests.cs b/ErsatzTV.Infrastructure.Tests/Metadata/LocalStatisticsProviderTests.cs index 9a0e78ef2..038670d68 100644 --- a/ErsatzTV.Infrastructure.Tests/Metadata/LocalStatisticsProviderTests.cs +++ b/ErsatzTV.Infrastructure.Tests/Metadata/LocalStatisticsProviderTests.cs @@ -2,6 +2,7 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.Infrastructure.Metadata; using Microsoft.Extensions.Logging; using NSubstitute; @@ -22,6 +23,7 @@ public class LocalStatisticsProviderTests Substitute.For(), Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For>()); var input = new LocalStatisticsProvider.FFprobe( diff --git a/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs index 4da341eaa..cd14d2218 100644 --- a/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs @@ -11,6 +11,7 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.FFmpeg.Capabilities; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using File = TagLib.File; @@ -20,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Metadata; public class LocalStatisticsProvider : ILocalStatisticsProvider { private readonly IClient _client; + private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory; private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly IMetadataRepository _metadataRepository; @@ -28,11 +30,13 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider IMetadataRepository metadataRepository, ILocalFileSystem localFileSystem, IClient client, + IHardwareCapabilitiesFactory hardwareCapabilitiesFactory, ILogger logger) { _metadataRepository = metadataRepository; _localFileSystem = localFileSystem; _client = client; + _hardwareCapabilitiesFactory = hardwareCapabilitiesFactory; _logger = logger; } @@ -52,6 +56,12 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider try { string filePath = await PathForMediaItem(mediaItem); + + if (Path.GetExtension(filePath) == ".avs" && !_hardwareCapabilitiesFactory.IsAviSynthInstalled()) + { + return BaseError.New(".avs files are not supported; compatible ffmpeg and avisynth are both required"); + } + return await RefreshStatistics(ffmpegPath, ffprobePath, mediaItem, filePath); } catch (Exception ex) diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index cf3bdfc37..18127ca6c 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -352,6 +352,7 @@ public class TranscodingTests metadataRepository, new LocalFileSystem(Substitute.For(), LoggerFactory.CreateLogger()), Substitute.For(), + Substitute.For(), LoggerFactory.CreateLogger()); await localStatisticsProvider.RefreshStatistics(ExecutableName("ffmpeg"), ExecutableName("ffprobe"), song); @@ -500,6 +501,7 @@ public class TranscodingTests metadataRepository, new LocalFileSystem(Substitute.For(), LoggerFactory.CreateLogger()), Substitute.For(), + Substitute.For(), LoggerFactory.CreateLogger()); await localStatisticsProvider.RefreshStatistics( diff --git a/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs b/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs new file mode 100644 index 000000000..b9804e8ce --- /dev/null +++ b/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Scanner.Application.FFmpeg; + +public record RefreshFFmpegCapabilities : IRequest; diff --git a/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs b/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs new file mode 100644 index 000000000..918415e07 --- /dev/null +++ b/ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs @@ -0,0 +1,45 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.FFmpeg.Capabilities; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Scanner.Application.FFmpeg; + +public class RefreshFFmpegCapabilitiesHandler( + IDbContextFactory dbContextFactory, + IHardwareCapabilitiesFactory hardwareCapabilitiesFactory, + ILocalStatisticsProvider localStatisticsProvider) + : IRequestHandler +{ + public async Task Handle(RefreshFFmpegCapabilities request, CancellationToken cancellationToken) + { + hardwareCapabilitiesFactory.ClearCache(); + + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybeFFmpegPath = await dbContext.ConfigElements + .GetValue(ConfigElementKey.FFmpegPath, cancellationToken) + .FilterT(File.Exists); + + foreach (string ffmpegPath in maybeFFmpegPath) + { + _ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath); + + Option maybeFFprobePath = await dbContext.ConfigElements + .GetValue(ConfigElementKey.FFprobePath, cancellationToken) + .FilterT(File.Exists); + + foreach (string ffprobePath in maybeFFprobePath) + { + Either result = await localStatisticsProvider.GetStatistics( + ffprobePath, + Path.Combine(FileSystemLayout.ResourcesCacheFolder, "test.avs")); + + hardwareCapabilitiesFactory.SetAviSynthInstalled(result.IsRight); + } + } + } +} diff --git a/ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs b/ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs index 2c4cd76e4..02e9ca7ef 100644 --- a/ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs @@ -19,7 +19,7 @@ public abstract class LocalFolderScanner { public static readonly ImmutableHashSet VideoFileExtensions = new[] { - ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".ogv", ".mp4", + ".avs", ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".ogv", ".mp4", ".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".m2ts", ".ts", ".webm" }.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); diff --git a/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj.DotSettings b/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj.DotSettings index a21ca000b..e89e1b47b 100644 --- a/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj.DotSettings +++ b/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj.DotSettings @@ -1,5 +1,6 @@  True + True True True True \ No newline at end of file diff --git a/ErsatzTV.Scanner/Program.cs b/ErsatzTV.Scanner/Program.cs index 7f608e2ad..500c2abd7 100644 --- a/ErsatzTV.Scanner/Program.cs +++ b/ErsatzTV.Scanner/Program.cs @@ -17,6 +17,7 @@ using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; using ErsatzTV.Core.Search; +using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; @@ -207,6 +208,7 @@ public class Program services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/ErsatzTV.Scanner/Worker.cs b/ErsatzTV.Scanner/Worker.cs index 123b385dd..1a79ec136 100644 --- a/ErsatzTV.Scanner/Worker.cs +++ b/ErsatzTV.Scanner/Worker.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Diagnostics; using ErsatzTV.Scanner.Application.Emby; +using ErsatzTV.Scanner.Application.FFmpeg; using ErsatzTV.Scanner.Application.Jellyfin; using ErsatzTV.Scanner.Application.MediaSources; using ErsatzTV.Scanner.Application.Plex; @@ -28,6 +29,10 @@ public class Worker : BackgroundService protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + await mediator.Send(new RefreshFFmpegCapabilities(), stoppingToken); + RootCommand rootCommand = ConfigureCommandLine(); // need to strip program name (head) from command line args diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index fd0ae2f8b..283ca9d02 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -104,6 +104,7 @@ + diff --git a/ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor b/ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor index f3208d82e..b0cf038a0 100644 --- a/ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor +++ b/ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor @@ -157,6 +157,11 @@ info.VideoControllers, info.Health, info.FFmpegSettings, + AviSynth = new + { + Demuxer = info.AviSynthDemuxer, + Installed = info.AviSynthInstalled + }, info.Channels, info.FFmpegProfiles }, diff --git a/ErsatzTV/Resources/test.avs b/ErsatzTV/Resources/test.avs new file mode 100644 index 000000000..5e656113d --- /dev/null +++ b/ErsatzTV/Resources/test.avs @@ -0,0 +1,2 @@ +ColorBars(width=640, height=360, pixel_type="YV12") +Trim(0, 299) diff --git a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs index e5e02b09c..e95f9075f 100644 --- a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs +++ b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs @@ -26,6 +26,7 @@ public class ResourceExtractorService : BackgroundService await ExtractResource(assembly, "ErsatzTV.png", stoppingToken); await ExtractResource(assembly, "sequential-schedule.schema.json", stoppingToken); await ExtractResource(assembly, "sequential-schedule-import.schema.json", stoppingToken); + await ExtractResource(assembly, "test.avs", stoppingToken); await ExtractFontResource(assembly, "Sen.ttf", stoppingToken); await ExtractFontResource(assembly, "Roboto-Regular.ttf", stoppingToken); diff --git a/ErsatzTV/Services/SchedulerService.cs b/ErsatzTV/Services/SchedulerService.cs index 80d2df68b..b278117fe 100644 --- a/ErsatzTV/Services/SchedulerService.cs +++ b/ErsatzTV/Services/SchedulerService.cs @@ -4,6 +4,7 @@ using Bugsnag; using ErsatzTV.Application; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Emby; +using ErsatzTV.Application.FFmpeg; using ErsatzTV.Application.Graphics; using ErsatzTV.Application.Jellyfin; using ErsatzTV.Application.Maintenance; @@ -65,6 +66,7 @@ public class SchedulerService : BackgroundService // run once immediately at startup if (!stoppingToken.IsCancellationRequested) { + await QueueFFmpegCapabilitiesRefresh(stoppingToken); await DoWork(stoppingToken); } @@ -396,4 +398,7 @@ public class SchedulerService : BackgroundService private ValueTask ReleaseMemory(CancellationToken cancellationToken) => _workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken); + + private ValueTask QueueFFmpegCapabilitiesRefresh(CancellationToken cancellationToken) => + _workerChannel.WriteAsync(new RefreshFFmpegCapabilities(), cancellationToken); } diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs index 1b942838f..6e832061e 100644 --- a/ErsatzTV/Services/WorkerService.cs +++ b/ErsatzTV/Services/WorkerService.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using Bugsnag; using ErsatzTV.Application; using ErsatzTV.Application.Channels; +using ErsatzTV.Application.FFmpeg; using ErsatzTV.Application.Graphics; using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.MediaCollections; @@ -52,6 +53,9 @@ public class WorkerService : BackgroundService switch (request) { + case RefreshFFmpegCapabilities refreshFFmpegCapabilities: + await mediator.Send(refreshFFmpegCapabilities, stoppingToken); + break; case RefreshChannelList refreshChannelList: await mediator.Send(refreshChannelList, stoppingToken); break;