From 17dcbfc344d199331eba59c47b850b3a4bfb69bd Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sun, 12 Mar 2023 13:00:54 -0500 Subject: [PATCH] add troubleshooting page (#1206) --- CHANGELOG.md | 4 + .../HealthCheckResultSummary.cs | 3 + .../Queries/GetTroubleshootingInfo.cs | 3 + .../Queries/GetTroubleshootingInfoHandler.cs | 193 ++++++++++++++++++ .../Troubleshooting/TroubleshootingInfo.cs | 13 ++ .../FFmpeg/FFmpegLibraryProcessService.cs | 2 +- .../HardwareCapabilitiesFactory.cs | 93 +++++---- .../IHardwareCapabilitiesFactory.cs | 4 + ErsatzTV/Pages/Troubleshooting.razor | 87 ++++++++ ErsatzTV/Shared/MainLayout.razor | 5 +- ErsatzTV/Shared/MarkdownView.razor.cs | 20 +- 11 files changed, 385 insertions(+), 42 deletions(-) create mode 100644 ErsatzTV.Application/Troubleshooting/HealthCheckResultSummary.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfo.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs create mode 100644 ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs create mode 100644 ErsatzTV/Pages/Troubleshooting.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6035bcf..721921807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- Add `Troubleshooting` page with aggregated settings/hardware accel info for easy reference + ### Fixed - Fix scaling anamorphic content from non-local libraries - Fix direct streaming content from Jellyfin that has external subtitles @@ -11,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix Jellyfin, Emby and Plex library scans that wouldn't work in certain timezones - Fix song normalization to match FFmpeg Profile bit depth - Fix bug playing some external subtitle files (e.g. with an apostrophe in the file name) +- Fix bug detecting VAAPI capabilities when no device is selected in active FFmpeg Profile ### Changed - Ignore case of video and audio file extensions in local folder scanner diff --git a/ErsatzTV.Application/Troubleshooting/HealthCheckResultSummary.cs b/ErsatzTV.Application/Troubleshooting/HealthCheckResultSummary.cs new file mode 100644 index 000000000..18bed0b34 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/HealthCheckResultSummary.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Troubleshooting; + +public record HealthCheckResultSummary(string Title, string Message); diff --git a/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfo.cs b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfo.cs new file mode 100644 index 000000000..4f1200d7c --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfo.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public record GetTroubleshootingInfo : IRequest; diff --git a/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs new file mode 100644 index 000000000..7379d85d2 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs @@ -0,0 +1,193 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Runtime.InteropServices; +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.FFmpeg.Capabilities; +using ErsatzTV.FFmpeg.Runtime; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Runtime; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace ErsatzTV.Application.Troubleshooting.Queries; + +public class GetTroubleshootingInfoHandler : IRequestHandler +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly IHealthCheckService _healthCheckService; + private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory; + private readonly IConfigElementRepository _configElementRepository; + private readonly IRuntimeInfo _runtimeInfo; + private readonly IMemoryCache _memoryCache; + + public GetTroubleshootingInfoHandler( + IDbContextFactory dbContextFactory, + IHealthCheckService healthCheckService, + IHardwareCapabilitiesFactory hardwareCapabilitiesFactory, + IConfigElementRepository configElementRepository, + IRuntimeInfo runtimeInfo, + IMemoryCache memoryCache) + { + _dbContextFactory = dbContextFactory; + _healthCheckService = healthCheckService; + _hardwareCapabilitiesFactory = hardwareCapabilitiesFactory; + _configElementRepository = configElementRepository; + _runtimeInfo = runtimeInfo; + _memoryCache = memoryCache; + } + + public async Task Handle(GetTroubleshootingInfo request, CancellationToken cancellationToken) + { + List healthCheckResults = await _healthCheckService.PerformHealthChecks(cancellationToken); + + string version = Assembly.GetEntryAssembly()? + .GetCustomAttribute()? + .InformationalVersion ?? "unknown"; + + var healthCheckSummaries = healthCheckResults + .Filter(r => r.Status is HealthCheckStatus.Warning or HealthCheckStatus.Fail) + .Map(r => new HealthCheckResultSummary(r.Title, r.Message)) + .ToList(); + + FFmpegSettingsViewModel ffmpegSettings = await GetFFmpegSettings(); + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + List channels = await dbContext.Channels + .AsNoTracking() + .ToListAsync(cancellationToken); + + var channelFFmpegProfiles = channels + .Map(c => c.FFmpegProfileId) + .ToImmutableHashSet(); + + List ffmpegProfiles = await dbContext.FFmpegProfiles + .AsNoTracking() + .Include(p => p.Resolution) + .ToListAsync(cancellationToken); + + var activeFFmpegProfiles = ffmpegProfiles + .Filter(f => channelFFmpegProfiles.Contains(f.Id)) + .ToList(); + + string nvidiaCapabilities = null; + string vaapiCapabilities = null; + Option maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath); + if (maybeFFmpegPath.IsNone) + { + nvidiaCapabilities = "Unable to locate ffmpeg"; + } + else + { + foreach (ConfigElement ffmpegPath in maybeFFmpegPath) + { + nvidiaCapabilities = await _hardwareCapabilitiesFactory.GetNvidiaOutput(ffmpegPath.Value); + + if (_runtimeInfo.IsOSPlatform(OSPlatform.Linux)) + { + var allDrivers = new List + { VaapiDriver.iHD, VaapiDriver.i965, VaapiDriver.RadeonSI, VaapiDriver.Nouveau }; + + foreach (VaapiDriver activeDriver in allDrivers) + { + if (!_memoryCache.TryGetValue("ffmpeg.render_devices", out List vaapiDevices)) + { + vaapiDevices = new List { "/dev/dri/renderD128" }; + } + + foreach (string vaapiDevice in vaapiDevices) + { + foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput( + Optional(GetDriverName(activeDriver)), + vaapiDevice)) + { + vaapiCapabilities += $"Checking driver {activeDriver} device {vaapiDevice} {Environment.NewLine} {Environment.NewLine}"; + vaapiCapabilities += $@"```shell +{output} +```"; + vaapiCapabilities += " " + Environment.NewLine + " " + Environment.NewLine; + } + } + } + } + } + } + + return new TroubleshootingInfo( + version, + healthCheckSummaries, + ffmpegSettings, + activeFFmpegProfiles, + channels, + nvidiaCapabilities, + vaapiCapabilities); + } + + // lifted from GetFFmpegSettingsHandler + private async Task GetFFmpegSettings() + { + Option ffmpegPath = await _configElementRepository.GetValue(ConfigElementKey.FFmpegPath); + Option ffprobePath = await _configElementRepository.GetValue(ConfigElementKey.FFprobePath); + Option defaultFFmpegProfileId = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegDefaultProfileId); + Option saveReports = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegSaveReports); + Option preferredAudioLanguageCode = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegPreferredLanguageCode); + Option watermark = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegGlobalWatermarkId); + Option fallbackFiller = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegGlobalFallbackFillerId); + Option hlsSegmenterIdleTimeout = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegSegmenterTimeout); + Option workAheadSegmenterLimit = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegWorkAheadSegmenters); + Option initialSegmentCount = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegInitialSegmentCount); + + var result = new FFmpegSettingsViewModel + { + FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty), + FFprobePath = await ffprobePath.IfNoneAsync(string.Empty), + DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0), + SaveReports = await saveReports.IfNoneAsync(false), + PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"), + HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60), + WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1), + InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1) + }; + + foreach (int watermarkId in watermark) + { + result.GlobalWatermarkId = watermarkId; + } + + foreach (int fallbackFillerId in fallbackFiller) + { + result.GlobalFallbackFillerId = fallbackFillerId; + } + + return result; + } + + private string GetDriverName(VaapiDriver driver) + { + switch (driver) + { + case VaapiDriver.i965: + return "i965"; + case VaapiDriver.iHD: + return "iHD"; + case VaapiDriver.RadeonSI: + return "radeonsi"; + case VaapiDriver.Nouveau: + return "nouveau"; + } + + return null; + } +} diff --git a/ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs b/ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs new file mode 100644 index 000000000..7effb0ad6 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Troubleshooting; + +public record TroubleshootingInfo( + string Version, + IEnumerable Health, + FFmpegSettingsViewModel FFmpegSettings, + IEnumerable FFmpegProfiles, + IEnumerable Channels, + string NvidiaCapabilities, + string VaapiCapabilities); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 3eefbcc44..c8ae253ab 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -701,7 +701,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService private static Option VaapiDeviceName(HardwareAccelerationMode accelerationMode, string vaapiDevice) => accelerationMode == HardwareAccelerationMode.Vaapi || OperatingSystem.IsLinux() && accelerationMode == HardwareAccelerationMode.Qsv - ? vaapiDevice + ? string.IsNullOrWhiteSpace(vaapiDevice) ? "/dev/dri/renderD128" : vaapiDevice : Option.None; private static string GetVideoFormat(FFmpegPlaybackSettings playbackSettings) => diff --git a/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs b/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs index 3aff72aeb..11eb2f039 100644 --- a/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs +++ b/ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs @@ -50,6 +50,56 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory }; } + public async Task GetNvidiaOutput(string ffmpegPath) + { + string[] arguments = + { + "-f", "lavfi", + "-i", "nullsrc", + "-c:v", "h264_nvenc", + "-gpu", "list", + "-f", "null", "-" + }; + + BufferedCommandResult result = await Cli.Wrap(ffmpegPath) + .WithArguments(arguments) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(Encoding.UTF8); + + string output = string.IsNullOrWhiteSpace(result.StandardOutput) + ? result.StandardError + : result.StandardOutput; + + return output; + } + + public async Task> GetVaapiOutput(Option vaapiDriver, string vaapiDevice) + { + BufferedCommandResult whichResult = await Cli.Wrap("which") + .WithArguments("vainfo") + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(Encoding.UTF8); + + if (whichResult.ExitCode != 0) + { + return Option.None; + } + + var envVars = new Dictionary(); + foreach (string libvaDriverName in vaapiDriver) + { + envVars.Add("LIBVA_DRIVER_NAME", libvaDriverName); + } + + BufferedCommandResult result = await Cli.Wrap("vainfo") + .WithArguments($"--display drm --device {vaapiDevice}") + .WithEnvironmentVariables(envVars) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(Encoding.UTF8); + + return result.StandardOutput; + } + private async Task> GetFFmpegCapabilities(string ffmpegPath, string capabilities) { var cacheKey = string.Format(FFmpegCapabilitiesCacheKeyFormat, capabilities); @@ -109,32 +159,16 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory return new VaapiHardwareCapabilities(profileEntrypoints, _logger); } - BufferedCommandResult whichResult = await Cli.Wrap("which") - .WithArguments("vainfo") - .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(Encoding.UTF8); - - if (whichResult.ExitCode != 0) + Option output = await GetVaapiOutput(vaapiDriver, device); + if (output.IsNone) { _logger.LogWarning("Unable to determine VAAPI capabilities; please install vainfo"); return new DefaultHardwareCapabilities(); } - var envVars = new Dictionary(); - foreach (string libvaDriverName in vaapiDriver) - { - envVars.Add("LIBVA_DRIVER_NAME", libvaDriverName); - } - - BufferedCommandResult result = await Cli.Wrap("vainfo") - .WithArguments($"--display drm --device {device}") - .WithEnvironmentVariables(envVars) - .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(Encoding.UTF8); - profileEntrypoints = new List(); - foreach (string line in result.StandardOutput.Split("\n")) + foreach (string line in string.Join("", output).Split("\n")) { const string PROFILE_ENTRYPOINT_PATTERN = @"(VAProfile\w*).*(VAEntrypoint\w*)"; Match match = Regex.Match(line, PROFILE_ENTRYPOINT_PATTERN); @@ -187,23 +221,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory _logger); } - string[] arguments = - { - "-f", "lavfi", - "-i", "nullsrc", - "-c:v", "h264_nvenc", - "-gpu", "list", - "-f", "null", "-" - }; - - BufferedCommandResult result = await Cli.Wrap(ffmpegPath) - .WithArguments(arguments) - .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(Encoding.UTF8); - - string output = string.IsNullOrWhiteSpace(result.StandardOutput) - ? result.StandardError - : result.StandardOutput; + string output = await GetNvidiaOutput(ffmpegPath); Option maybeLine = Optional(output.Split("\n").FirstOrDefault(x => x.Contains("GPU"))); foreach (string line in maybeLine) @@ -226,8 +244,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory } _logger.LogWarning( - "Error detecting NVIDIA GPU capabilities; some hardware accelerated features will be unavailable: {ExitCode}", - result.ExitCode); + "Error detecting NVIDIA GPU capabilities; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); } diff --git a/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs b/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs index f00761a61..a770bbfb0 100644 --- a/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs +++ b/ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs @@ -10,4 +10,8 @@ public interface IHardwareCapabilitiesFactory HardwareAccelerationMode hardwareAccelerationMode, Option vaapiDriver, Option vaapiDevice); + + Task GetNvidiaOutput(string ffmpegPath); + + Task> GetVaapiOutput(Option vaapiDriver, string vaapiDevice); } diff --git a/ErsatzTV/Pages/Troubleshooting.razor b/ErsatzTV/Pages/Troubleshooting.razor new file mode 100644 index 000000000..e42504b50 --- /dev/null +++ b/ErsatzTV/Pages/Troubleshooting.razor @@ -0,0 +1,87 @@ +@page "/system/troubleshooting" +@using ErsatzTV.Application.Troubleshooting.Queries +@using System.Text.Json +@using System.Text.Json.Serialization +@using ErsatzTV.Application.Troubleshooting +@implements IDisposable +@inject IMediator Mediator + + + + + + General + + + + + + + + + + Nvidia Capabilities + + + + + + + + + + Vaapi Capabilities + + + + + + + + +@code { + private readonly CancellationTokenSource _cts = new(); + private string _troubleshootingInfo; + private string _nvidiaCapabilities; + private string _vaapiCapabilities; + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } + + protected override async Task OnParametersSetAsync() + { + try + { + TroubleshootingInfo info = await Mediator.Send(new GetTroubleshootingInfo(), _cts.Token); + + string json = JsonSerializer.Serialize( + new { info.Version, info.Health, info.FFmpegSettings, info.Channels, info.FFmpegProfiles }, + new JsonSerializerOptions + { + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }); + + _troubleshootingInfo = $@"```json +{json} +```"; + string formattedCapabilities = info.NvidiaCapabilities.Replace("\n", $" {Environment.NewLine}"); + + _nvidiaCapabilities = $@"```shell +{formattedCapabilities} +```"; + + _vaapiCapabilities = info.VaapiCapabilities; + } + catch (Exception ex) + { + _troubleshootingInfo = $@"``` +{ex} +```"; + } + } +} \ No newline at end of file diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index 0986edbe7..541a03be4 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -111,7 +111,10 @@ Schedules Playouts Settings - Logs + + Logs + Troubleshooting + ErsatzTV Version diff --git a/ErsatzTV/Shared/MarkdownView.razor.cs b/ErsatzTV/Shared/MarkdownView.razor.cs index ace4627c9..b376e884f 100644 --- a/ErsatzTV/Shared/MarkdownView.razor.cs +++ b/ErsatzTV/Shared/MarkdownView.razor.cs @@ -18,14 +18,30 @@ public partial class MarkdownView [Parameter] public string Content { get; set; } - public MarkupString HtmlContent => _markupContent ?? (_markupContent = ConvertStringToMarkupString(Content)).Value; + public MarkupString? HtmlContent + { + get + { + if (string.IsNullOrWhiteSpace(Content)) + { + return null; + } + + return _markupContent ?? (_markupContent = ConvertStringToMarkupString(Content)).Value; + } + } private MarkupString ConvertStringToMarkupString(string value) { if (!string.IsNullOrWhiteSpace(value)) { // Convert markdown string to HTML - string html = Markdown.ToHtml(value, new MarkdownPipelineBuilder().UseAdvancedExtensions().Build()); + string html = Markdown.ToHtml( + value, + new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseSoftlineBreakAsHardlineBreak() + .Build()); // Sanitize HTML before rendering string sanitizedHtml = HtmlSanitizer.Sanitize(html);