Browse Source

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
pull/2601/head
Jason Dove 2 months ago committed by GitHub
parent
commit
132466b3d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
  3. 3
      ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs
  4. 45
      ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs
  5. 53
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  6. 9
      ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs
  7. 2
      ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs
  8. 11
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  9. 41
      ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs
  10. 5
      ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFilter.cs
  11. 19
      ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFormat.cs
  12. 203
      ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs
  13. 1
      ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs
  14. 6
      ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs
  15. 1
      ErsatzTV.FFmpeg/Format/VideoFormat.cs
  16. 2
      ErsatzTV.Infrastructure.Tests/Metadata/LocalStatisticsProviderTests.cs
  17. 10
      ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs
  18. 2
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  19. 3
      ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs
  20. 45
      ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs
  21. 2
      ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs
  22. 1
      ErsatzTV.Scanner/ErsatzTV.Scanner.csproj.DotSettings
  23. 2
      ErsatzTV.Scanner/Program.cs
  24. 5
      ErsatzTV.Scanner/Worker.cs
  25. 1
      ErsatzTV/ErsatzTV.csproj
  26. 5
      ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor
  27. 2
      ErsatzTV/Resources/test.avs
  28. 1
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs
  29. 5
      ErsatzTV/Services/SchedulerService.cs
  30. 4
      ErsatzTV/Services/WorkerService.cs

5
CHANGELOG.md

@ -23,6 +23,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

1
ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings

@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=emby_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpeg_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Ccommands/@EntryIndexedValue">True</s:Boolean>

3
ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.FFmpeg;
public record RefreshFFmpegCapabilities : IRequest, IBackgroundServiceRequest;

45
ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs

@ -0,0 +1,45 @@ @@ -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<TvContext> dbContextFactory,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
ILocalStatisticsProvider localStatisticsProvider)
: IRequestHandler<RefreshFFmpegCapabilities>
{
public async Task Handle(RefreshFFmpegCapabilities request, CancellationToken cancellationToken)
{
hardwareCapabilitiesFactory.ClearCache();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<string> maybeFFmpegPath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists);
foreach (string ffmpegPath in maybeFFmpegPath)
{
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
Option<string> maybeFFprobePath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
.FilterT(File.Exists);
foreach (string ffprobePath in maybeFFprobePath)
{
Either<BaseError, MediaVersion> result = await localStatisticsProvider.GetStatistics(
ffprobePath,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "test.avs"));
hardwareCapabilitiesFactory.SetAviSynthInstalled(result.IsRight);
}
}
}
}

53
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -1,6 +1,7 @@ @@ -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; @@ -9,22 +10,12 @@ using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.FFmpegProfiles;
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdateFFmpegSettingsHandler(
public class UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
{
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
_workerChannel = workerChannel;
}
public Task<Either<BaseError, Unit>> Handle(
UpdateFFmpegSettings request,
CancellationToken cancellationToken) =>
@ -44,7 +35,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, @@ -44,7 +35,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
private async Task<Validation<BaseError, Unit>> 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<UpdateFFmpegSettings, @@ -71,21 +62,21 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
private async Task<Unit> 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<UpdateFFmpegSettings, @@ -95,12 +86,12 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
}
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegPreferredLanguageCode,
request.Settings.PreferredAudioLanguageCode,
cancellationToken);
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegUseEmbeddedSubtitles,
request.Settings.UseEmbeddedSubtitles,
cancellationToken);
@ -111,7 +102,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, @@ -111,7 +102,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
request.Settings.ExtractEmbeddedSubtitles = false;
}
await _configElementRepository.Upsert(
await configElementRepository.Upsert(
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
request.Settings.ExtractEmbeddedSubtitles,
cancellationToken);
@ -119,48 +110,50 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings, @@ -119,48 +110,50 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
// queue extracting all embedded subtitles
if (request.Settings.ExtractEmbeddedSubtitles)
{
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.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;
}
}

9
ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs

@ -73,6 +73,9 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI @@ -73,6 +73,9 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
.AsNoTracking()
.ToListAsync(cancellationToken);
bool aviSynthDemuxer = false;
bool aviSynthInstalled = false;
string nvidiaCapabilities = null;
StringBuilder qsvCapabilities = new();
StringBuilder vaapiCapabilities = new();
@ -157,6 +160,10 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI @@ -157,6 +160,10 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
videoToolboxCapabilities.AppendLine();
videoToolboxCapabilities.AppendLine();
}
var ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value);
aviSynthDemuxer = ffmpegCapabilities.HasDemuxFormat(FFmpegKnownFormat.AviSynth);
aviSynthInstalled = _hardwareCapabilitiesFactory.IsAviSynthInstalled();
}
}
@ -189,6 +196,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI @@ -189,6 +196,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
ffmpegProfiles,
channels,
channelWatermarks,
aviSynthDemuxer,
aviSynthInstalled,
nvidiaCapabilities,
qsvCapabilities.ToString(),
vaapiCapabilities.ToString(),

2
ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs

@ -14,6 +14,8 @@ public record TroubleshootingInfo( @@ -14,6 +14,8 @@ public record TroubleshootingInfo(
List<FFmpegProfile> FFmpegProfiles,
List<Channel> Channels,
List<ChannelWatermark> Watermarks,
bool AviSynthDemuxer,
bool AviSynthInstalled,
string NvidiaCapabilities,
string QsvCapabilities,
string VaapiCapabilities,

11
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -544,16 +544,11 @@ public class PipelineBuilderBaseTests @@ -544,16 +544,11 @@ public class PipelineBuilderBaseTests
return command;
}
public class DefaultFFmpegCapabilities : FFmpegCapabilities
{
public DefaultFFmpegCapabilities()
: base(
public class DefaultFFmpegCapabilities() : FFmpegCapabilities(
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>())
{
}
}
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>());
}

41
ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs

@ -3,34 +3,21 @@ using ErsatzTV.FFmpeg.Format; @@ -3,34 +3,21 @@ using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Capabilities;
public class FFmpegCapabilities : IFFmpegCapabilities
{
private readonly IReadOnlySet<string> _ffmpegDecoders;
private readonly IReadOnlySet<string> _ffmpegEncoders;
private readonly IReadOnlySet<string> _ffmpegFilters;
private readonly IReadOnlySet<string> _ffmpegHardwareAccelerations;
private readonly IReadOnlySet<string> _ffmpegOptions;
public FFmpegCapabilities(
public class FFmpegCapabilities(
IReadOnlySet<string> ffmpegHardwareAccelerations,
IReadOnlySet<string> ffmpegDecoders,
IReadOnlySet<string> ffmpegFilters,
IReadOnlySet<string> ffmpegEncoders,
IReadOnlySet<string> ffmpegOptions)
IReadOnlySet<string> ffmpegOptions,
IReadOnlySet<string> ffmpegDemuxFormats)
: IFFmpegCapabilities
{
_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 @@ -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 @@ -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<IDecoder> SoftwareDecoderForVideoFormat(string videoFormat) =>
videoFormat switch
@ -83,9 +72,9 @@ public class FFmpegCapabilities : IFFmpegCapabilities @@ -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(),

5
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFilter.cs

@ -11,9 +11,8 @@ public record FFmpegKnownFilter @@ -11,9 +11,8 @@ public record FFmpegKnownFilter
public string Name { get; }
public static IList<string> AllFilters =>
new[]
{
[
ScaleNpp.Name,
TonemapOpenCL.Name
};
];
}

19
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFormat.cs

@ -0,0 +1,19 @@ @@ -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<string> AllFormats =>
[
AviSynth.Name
];
}

203
ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs

@ -1,4 +1,5 @@ @@ -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; @@ -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<HardwareCapabilitiesFactory> 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 @@ -36,19 +41,14 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
"-f", "null", "-"
};
private readonly ILogger<HardwareCapabilitiesFactory> _logger;
private readonly IMemoryCache _memoryCache;
private readonly IRuntimeInfo _runtimeInfo;
public HardwareCapabilitiesFactory(
IMemoryCache memoryCache,
IRuntimeInfo runtimeInfo,
ILogger<HardwareCapabilitiesFactory> 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<IFFmpegCapabilities> GetFFmpegCapabilities(string ffmpegPath)
@ -72,12 +72,16 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory @@ -72,12 +72,16 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
IReadOnlySet<string> ffmpegOptions = await GetFFmpegOptions(ffmpegPath)
.Map(set => set.Intersect(FFmpegKnownOption.AllOptions).ToImmutableHashSet());
IReadOnlySet<string> 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<IHardwareCapabilities> GetHardwareCapabilities(
@ -95,7 +99,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory @@ -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 @@ -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 @@ -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 @@ -288,13 +292,14 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
return [];
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
public List<string> GetVideoToolboxDecoders()
{
var result = new List<string>();
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 @@ -303,7 +308,27 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
return result;
}
public List<string> GetVideoToolboxEncoders() => VideoToolboxUtil.GetAvailableEncoders(_logger);
public List<string> 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<IReadOnlySet<string>> GetFFmpegCapabilities(
string ffmpegPath,
@ -311,13 +336,13 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory @@ -311,13 +336,13 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
Func<string, Option<string>> parseLine)
{
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, capabilities);
if (_memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? cachedCapabilities) &&
if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? 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 @@ -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<IReadOnlySet<string>> GetFFmpegOptions(string ffmpegPath)
{
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "options");
if (_memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? cachedCapabilities) &&
if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? 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 @@ -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<IReadOnlySet<string>> GetFFmpegFormats(string ffmpegPath, string muxDemux)
{
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "formats");
if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? 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<string> 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<string>.None;
}
private static Option<string> 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<string>.None;
}
private static Option<string> 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<string>.None;
}
private static Option<Tuple<string, string>> ParseFFmpegFormatLine(string input)
{
Match match = FormatRegex().Match(input);
return match.Success ? Tuple(match.Groups[1].Value, match.Groups[2].Value) : Option<Tuple<string, string>>.None;
}
private async Task<IHardwareCapabilities> GetVaapiCapabilities(
Option<string> vaapiDisplay,
Option<string> vaapiDriver,
@ -390,7 +457,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory @@ -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 @@ -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<VaapiProfileEntrypoint>? profileEntrypoints) &&
if (memoryCache.TryGetValue(cacheKey, out List<VaapiProfileEntrypoint>? profileEntrypoints) &&
profileEntrypoints is not null)
{
return new VaapiHardwareCapabilities(profileEntrypoints, _logger);
return new VaapiHardwareCapabilities(profileEntrypoints, logger);
}
Option<string> 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 @@ -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 @@ -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 @@ -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<VaapiProfileEntrypoint>? profileEntrypoints) &&
if (memoryCache.TryGetValue(cacheKey, out List<VaapiProfileEntrypoint>? 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<string>? vaapiDisplays))
if (!memoryCache.TryGetValue("ffmpeg.vaapi_displays", out List<string>? vaapiDisplays))
{
vaapiDisplays = ["drm"];
}
@ -499,7 +566,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory @@ -499,7 +566,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
Option<string> vaapiOutput = await GetVaapiOutput(vaapiDisplay, Option<string>.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 @@ -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 @@ -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 @@ -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 @@ -545,14 +612,14 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
Option<List<CudaDevice>> 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 @@ -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();
}

1
ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs

@ -9,5 +9,6 @@ public interface IFFmpegCapabilities @@ -9,5 +9,6 @@ public interface IFFmpegCapabilities
bool HasEncoder(FFmpegKnownEncoder encoder);
bool HasFilter(FFmpegKnownFilter filter);
bool HasOption(FFmpegKnownOption ffmpegOption);
bool HasDemuxFormat(FFmpegKnownFormat format);
Option<IDecoder> SoftwareDecoderForVideoFormat(string videoFormat);
}

6
ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs

@ -4,6 +4,8 @@ namespace ErsatzTV.FFmpeg.Capabilities; @@ -4,6 +4,8 @@ namespace ErsatzTV.FFmpeg.Capabilities;
public interface IHardwareCapabilitiesFactory
{
void ClearCache();
Task<IFFmpegCapabilities> GetFFmpegCapabilities(string ffmpegPath);
Task<IHardwareCapabilities> GetHardwareCapabilities(
@ -29,4 +31,8 @@ public interface IHardwareCapabilitiesFactory @@ -29,4 +31,8 @@ public interface IHardwareCapabilitiesFactory
List<string> GetVideoToolboxDecoders();
List<string> GetVideoToolboxEncoders();
void SetAviSynthInstalled(bool aviSynthInstalled);
bool IsAviSynthInstalled();
}

1
ErsatzTV.FFmpeg/Format/VideoFormat.cs

@ -15,6 +15,7 @@ public static class VideoFormat @@ -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";

2
ErsatzTV.Infrastructure.Tests/Metadata/LocalStatisticsProviderTests.cs

@ -2,6 +2,7 @@ @@ -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 @@ -22,6 +23,7 @@ public class LocalStatisticsProviderTests
Substitute.For<IMetadataRepository>(),
Substitute.For<ILocalFileSystem>(),
Substitute.For<IClient>(),
Substitute.For<IHardwareCapabilitiesFactory>(),
Substitute.For<ILogger<LocalStatisticsProvider>>());
var input = new LocalStatisticsProvider.FFprobe(

10
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -11,6 +11,7 @@ using ErsatzTV.Core.Domain; @@ -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; @@ -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<LocalStatisticsProvider> _logger;
private readonly IMetadataRepository _metadataRepository;
@ -28,11 +30,13 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -28,11 +30,13 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
IMetadataRepository metadataRepository,
ILocalFileSystem localFileSystem,
IClient client,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
ILogger<LocalStatisticsProvider> logger)
{
_metadataRepository = metadataRepository;
_localFileSystem = localFileSystem;
_client = client;
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
_logger = logger;
}
@ -52,6 +56,12 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -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)

2
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -352,6 +352,7 @@ public class TranscodingTests @@ -352,6 +352,7 @@ public class TranscodingTests
metadataRepository,
new LocalFileSystem(Substitute.For<IClient>(), LoggerFactory.CreateLogger<LocalFileSystem>()),
Substitute.For<IClient>(),
Substitute.For<IHardwareCapabilitiesFactory>(),
LoggerFactory.CreateLogger<LocalStatisticsProvider>());
await localStatisticsProvider.RefreshStatistics(ExecutableName("ffmpeg"), ExecutableName("ffprobe"), song);
@ -500,6 +501,7 @@ public class TranscodingTests @@ -500,6 +501,7 @@ public class TranscodingTests
metadataRepository,
new LocalFileSystem(Substitute.For<IClient>(), LoggerFactory.CreateLogger<LocalFileSystem>()),
Substitute.For<IClient>(),
Substitute.For<IHardwareCapabilitiesFactory>(),
LoggerFactory.CreateLogger<LocalStatisticsProvider>());
await localStatisticsProvider.RefreshStatistics(

3
ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilities.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Scanner.Application.FFmpeg;
public record RefreshFFmpegCapabilities : IRequest;

45
ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs

@ -0,0 +1,45 @@ @@ -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<TvContext> dbContextFactory,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
ILocalStatisticsProvider localStatisticsProvider)
: IRequestHandler<RefreshFFmpegCapabilities>
{
public async Task Handle(RefreshFFmpegCapabilities request, CancellationToken cancellationToken)
{
hardwareCapabilitiesFactory.ClearCache();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<string> maybeFFmpegPath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists);
foreach (string ffmpegPath in maybeFFmpegPath)
{
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
Option<string> maybeFFprobePath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
.FilterT(File.Exists);
foreach (string ffprobePath in maybeFFprobePath)
{
Either<BaseError, MediaVersion> result = await localStatisticsProvider.GetStatistics(
ffprobePath,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "test.avs"));
hardwareCapabilitiesFactory.SetAviSynthInstalled(result.IsRight);
}
}
}
}

2
ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs

@ -19,7 +19,7 @@ public abstract class LocalFolderScanner @@ -19,7 +19,7 @@ public abstract class LocalFolderScanner
{
public static readonly ImmutableHashSet<string> 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);

1
ErsatzTV.Scanner/ErsatzTV.Scanner.csproj.DotSettings

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=application_005Cemby_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=application_005Cffmpeg_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=application_005Cjellyfin_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=application_005Cmediasources_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=application_005Cplex_005Ccommands/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

2
ErsatzTV.Scanner/Program.cs

@ -17,6 +17,7 @@ using ErsatzTV.Core.Jellyfin; @@ -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 @@ -207,6 +208,7 @@ public class Program
services.AddScoped<IOtherVideoNfoReader, OtherVideoNfoReader>();
services.AddScoped<IFFmpegPngService, FFmpegPngService>();
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IHardwareCapabilitiesFactory, HardwareCapabilitiesFactory>();
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexOtherVideoLibraryScanner, PlexOtherVideoLibraryScanner>();

5
ErsatzTV.Scanner/Worker.cs

@ -1,6 +1,7 @@ @@ -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 @@ -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<IMediator>();
await mediator.Send(new RefreshFFmpegCapabilities(), stoppingToken);
RootCommand rootCommand = ConfigureCommandLine();
// need to strip program name (head) from command line args

1
ErsatzTV/ErsatzTV.csproj

@ -104,6 +104,7 @@ @@ -104,6 +104,7 @@
<EmbeddedResource Include="Resources\sequential-schedule-import.schema.json" />
<EmbeddedResource Include="Resources\sequential-schedule.schema.json" />
<EmbeddedResource Include="Resources\empty.sqlite3" />
<EmbeddedResource Include="Resources\test.avs" />
</ItemGroup>
<ItemGroup>

5
ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor

@ -157,6 +157,11 @@ @@ -157,6 +157,11 @@
info.VideoControllers,
info.Health,
info.FFmpegSettings,
AviSynth = new
{
Demuxer = info.AviSynthDemuxer,
Installed = info.AviSynthInstalled
},
info.Channels,
info.FFmpegProfiles
},

2
ErsatzTV/Resources/test.avs

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
ColorBars(width=640, height=360, pixel_type="YV12")
Trim(0, 299)

1
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -26,6 +26,7 @@ public class ResourceExtractorService : BackgroundService @@ -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);

5
ErsatzTV/Services/SchedulerService.cs

@ -4,6 +4,7 @@ using Bugsnag; @@ -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 @@ -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 @@ -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);
}

4
ErsatzTV/Services/WorkerService.cs

@ -3,6 +3,7 @@ using System.Threading.Channels; @@ -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 @@ -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;

Loading…
Cancel
Save