From 1eb51ad2f401fd783e956d84b94512cf0d989f20 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Fri, 17 Sep 2021 17:59:59 -0500 Subject: [PATCH] add some health checks to home page (#374) --- CHANGELOG.md | 11 +- .../Queries/GetAllHealthCheckResults.cs | 8 ++ .../GetAllHealthCheckResultsHandler.cs | 21 +++ .../Checks/IEpisodeMetadataHealthCheck.cs | 6 + .../Checks/IFFmpegReportsHealthCheck.cs | 6 + .../Checks/IFFmpegVersionHealthCheck.cs | 6 + .../IHardwareAccelerationHealthCheck.cs | 6 + .../Checks/IMovieMetadataHealthCheck.cs | 6 + .../Health/Checks/IZeroDurationHealthCheck.cs | 6 + ErsatzTV.Core/Health/HealthCheckResult.cs | 4 + ErsatzTV.Core/Health/HealthCheckStatus.cs | 10 ++ ErsatzTV.Core/Health/IHealthCheck.cs | 9 ++ ErsatzTV.Core/Health/IHealthCheckService.cs | 10 ++ .../Health/Checks/BaseHealthCheck.cs | 52 +++++++ .../Checks/EpisodeMetadataHealthCheck.cs | 53 ++++++++ .../Health/Checks/FFmpegReportsHealthCheck.cs | 37 +++++ .../Health/Checks/FFmpegVersionHealthCheck.cs | 110 +++++++++++++++ .../Checks/HardwareAccelerationHealthCheck.cs | 127 ++++++++++++++++++ .../Health/Checks/MovieMetadataHealthCheck.cs | 53 ++++++++ .../Health/Checks/ZeroDurationHealthCheck.cs | 54 ++++++++ .../Health/HealthCheckService.cs | 37 +++++ ErsatzTV/Pages/Index.razor | 52 +++++++ ErsatzTV/Startup.cs | 12 ++ 23 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 ErsatzTV.Application/Health/Queries/GetAllHealthCheckResults.cs create mode 100644 ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs create mode 100644 ErsatzTV.Core/Health/Checks/IEpisodeMetadataHealthCheck.cs create mode 100644 ErsatzTV.Core/Health/Checks/IFFmpegReportsHealthCheck.cs create mode 100644 ErsatzTV.Core/Health/Checks/IFFmpegVersionHealthCheck.cs create mode 100644 ErsatzTV.Core/Health/Checks/IHardwareAccelerationHealthCheck.cs create mode 100644 ErsatzTV.Core/Health/Checks/IMovieMetadataHealthCheck.cs create mode 100644 ErsatzTV.Core/Health/Checks/IZeroDurationHealthCheck.cs create mode 100644 ErsatzTV.Core/Health/HealthCheckResult.cs create mode 100644 ErsatzTV.Core/Health/HealthCheckStatus.cs create mode 100644 ErsatzTV.Core/Health/IHealthCheck.cs create mode 100644 ErsatzTV.Core/Health/IHealthCheckService.cs create mode 100644 ErsatzTV.Infrastructure/Health/Checks/BaseHealthCheck.cs create mode 100644 ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs create mode 100644 ErsatzTV.Infrastructure/Health/Checks/FFmpegReportsHealthCheck.cs create mode 100644 ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs create mode 100644 ErsatzTV.Infrastructure/Health/Checks/HardwareAccelerationHealthCheck.cs create mode 100644 ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs create mode 100644 ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs create mode 100644 ErsatzTV.Infrastructure/Health/HealthCheckService.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc4f7337..bf1489c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,17 @@ 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 `Health Checks` table to home page to identify and surface common misconfigurations + - `FFmpeg Version` checks `ffmpeg` and `ffprobe` versions + - `FFmpeg Reports` checks whether ffmpeg troubleshooting reports are enabled since they can use a lot of disk space over time + - `Hardware Acceleration` checks whether channels that transcode are using acceleration methods that ffmpeg claims to support + - `Movie Metadata` checks whether all movies have metadata (fallback metadata counts as metadata) + - `Episode Metadata` checks whether all episodes have metadata (fallback metadata counts as metadata) + - `Zero Duration` checks whether all movies and episodes have a valid (non-zero) duration + ### Fixed -- Fix scanning and indexing local movies and episodes with no metadata +- Fix scanning and indexing local movies and episodes without NFO metadata - Fix displaying seasons for shows with no year (in metadata or in folder name) ## [0.0.58-alpha] - 2021-09-15 diff --git a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResults.cs b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResults.cs new file mode 100644 index 000000000..e8fccba0f --- /dev/null +++ b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResults.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +using ErsatzTV.Core.Health; +using MediatR; + +namespace ErsatzTV.Application.Health.Queries +{ + public record GetAllHealthCheckResults : IRequest>; +} diff --git a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs new file mode 100644 index 000000000..9cf19fa1d --- /dev/null +++ b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Health; +using MediatR; + +namespace ErsatzTV.Application.Health.Queries +{ + public class GetAllHealthCheckResultsHandler : IRequestHandler> + { + private readonly IHealthCheckService _healthCheckService; + + public GetAllHealthCheckResultsHandler(IHealthCheckService healthCheckService) => + _healthCheckService = healthCheckService; + + public Task> Handle( + GetAllHealthCheckResults request, + CancellationToken cancellationToken) => + _healthCheckService.PerformHealthChecks(); + } +} diff --git a/ErsatzTV.Core/Health/Checks/IEpisodeMetadataHealthCheck.cs b/ErsatzTV.Core/Health/Checks/IEpisodeMetadataHealthCheck.cs new file mode 100644 index 000000000..25a9e2d38 --- /dev/null +++ b/ErsatzTV.Core/Health/Checks/IEpisodeMetadataHealthCheck.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Health.Checks +{ + public interface IEpisodeMetadataHealthCheck : IHealthCheck + { + } +} diff --git a/ErsatzTV.Core/Health/Checks/IFFmpegReportsHealthCheck.cs b/ErsatzTV.Core/Health/Checks/IFFmpegReportsHealthCheck.cs new file mode 100644 index 000000000..1edaf3d9e --- /dev/null +++ b/ErsatzTV.Core/Health/Checks/IFFmpegReportsHealthCheck.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Health.Checks +{ + public interface IFFmpegReportsHealthCheck : IHealthCheck + { + } +} diff --git a/ErsatzTV.Core/Health/Checks/IFFmpegVersionHealthCheck.cs b/ErsatzTV.Core/Health/Checks/IFFmpegVersionHealthCheck.cs new file mode 100644 index 000000000..92c95b3b0 --- /dev/null +++ b/ErsatzTV.Core/Health/Checks/IFFmpegVersionHealthCheck.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Health.Checks +{ + public interface IFFmpegVersionHealthCheck : IHealthCheck + { + } +} diff --git a/ErsatzTV.Core/Health/Checks/IHardwareAccelerationHealthCheck.cs b/ErsatzTV.Core/Health/Checks/IHardwareAccelerationHealthCheck.cs new file mode 100644 index 000000000..8431fa42b --- /dev/null +++ b/ErsatzTV.Core/Health/Checks/IHardwareAccelerationHealthCheck.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Health.Checks +{ + public interface IHardwareAccelerationHealthCheck : IHealthCheck + { + } +} diff --git a/ErsatzTV.Core/Health/Checks/IMovieMetadataHealthCheck.cs b/ErsatzTV.Core/Health/Checks/IMovieMetadataHealthCheck.cs new file mode 100644 index 000000000..ab9ffd207 --- /dev/null +++ b/ErsatzTV.Core/Health/Checks/IMovieMetadataHealthCheck.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Health.Checks +{ + public interface IMovieMetadataHealthCheck : IHealthCheck + { + } +} diff --git a/ErsatzTV.Core/Health/Checks/IZeroDurationHealthCheck.cs b/ErsatzTV.Core/Health/Checks/IZeroDurationHealthCheck.cs new file mode 100644 index 000000000..f226b0de8 --- /dev/null +++ b/ErsatzTV.Core/Health/Checks/IZeroDurationHealthCheck.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Health.Checks +{ + public interface IZeroDurationHealthCheck : IHealthCheck + { + } +} diff --git a/ErsatzTV.Core/Health/HealthCheckResult.cs b/ErsatzTV.Core/Health/HealthCheckResult.cs new file mode 100644 index 000000000..398b0fb45 --- /dev/null +++ b/ErsatzTV.Core/Health/HealthCheckResult.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.Health +{ + public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message); +} diff --git a/ErsatzTV.Core/Health/HealthCheckStatus.cs b/ErsatzTV.Core/Health/HealthCheckStatus.cs new file mode 100644 index 000000000..8d8c958bc --- /dev/null +++ b/ErsatzTV.Core/Health/HealthCheckStatus.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Health +{ + public enum HealthCheckStatus + { + Pass, + Fail, + Warning, + Info + } +} diff --git a/ErsatzTV.Core/Health/IHealthCheck.cs b/ErsatzTV.Core/Health/IHealthCheck.cs new file mode 100644 index 000000000..bfe63b6f9 --- /dev/null +++ b/ErsatzTV.Core/Health/IHealthCheck.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace ErsatzTV.Core.Health +{ + public interface IHealthCheck + { + Task Check(); + } +} diff --git a/ErsatzTV.Core/Health/IHealthCheckService.cs b/ErsatzTV.Core/Health/IHealthCheckService.cs new file mode 100644 index 000000000..83f85e62a --- /dev/null +++ b/ErsatzTV.Core/Health/IHealthCheckService.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ErsatzTV.Core.Health +{ + public interface IHealthCheckService + { + Task> PerformHealthChecks(); + } +} diff --git a/ErsatzTV.Infrastructure/Health/Checks/BaseHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/BaseHealthCheck.cs new file mode 100644 index 000000000..97eea9671 --- /dev/null +++ b/ErsatzTV.Infrastructure/Health/Checks/BaseHealthCheck.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using ErsatzTV.Core.Health; +using LanguageExt; +using Lucene.Net.Util; + +namespace ErsatzTV.Infrastructure.Health.Checks +{ + public abstract class BaseHealthCheck + { + protected abstract string Title { get; } + + protected HealthCheckResult Result(HealthCheckStatus status, string message) => + new(Title, status, message); + + protected HealthCheckResult OkResult() => + new(Title, HealthCheckStatus.Pass, string.Empty); + + protected HealthCheckResult FailResult(string message) => + new(Title, HealthCheckStatus.Fail, message); + + protected HealthCheckResult WarningResult(string message) => + new(Title, HealthCheckStatus.Warning, message); + + protected HealthCheckResult InfoResult(string message) => + new(Title, HealthCheckStatus.Info, message); + + protected static async Task GetProcessOutput(string path, IEnumerable arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = path, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + startInfo.ArgumentList.AddRange(arguments); + + var process = new Process + { + StartInfo = startInfo + }; + + process.Start(); + string result = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + return result; + } + } +} diff --git a/ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs new file mode 100644 index 000000000..1b5d1375e --- /dev/null +++ b/ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Health.Checks; +using ErsatzTV.Infrastructure.Data; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Health.Checks +{ + public class EpisodeMetadataHealthCheck : BaseHealthCheck, IEpisodeMetadataHealthCheck + { + private readonly IDbContextFactory _dbContextFactory; + + public EpisodeMetadataHealthCheck(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + protected override string Title => "Episode Metadata"; + + public async Task Check() + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + + List episodes = await dbContext.Episodes + .Filter(e => e.EpisodeMetadata.Count == 0) + .Include(e => e.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .ToListAsync(); + + if (episodes.Any()) + { + var paths = episodes.SelectMany(e => e.MediaVersions.Map(mv => mv.MediaFiles)) + .Flatten() + .Map(f => Optional(Path.GetDirectoryName(f.Path))) + .Sequence() + .Flatten() + .Distinct() + .Take(5) + .ToList(); + + var folders = string.Join(", ", paths); + + return WarningResult($"There are {episodes.Count} episodes with missing metadata, including in the following folders: {folders}"); + } + + return OkResult(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Health/Checks/FFmpegReportsHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/FFmpegReportsHealthCheck.cs new file mode 100644 index 000000000..31e11b92d --- /dev/null +++ b/ErsatzTV.Infrastructure/Health/Checks/FFmpegReportsHealthCheck.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Health.Checks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Infrastructure.Health.Checks +{ + public class FFmpegReportsHealthCheck : BaseHealthCheck, IFFmpegReportsHealthCheck + { + private readonly IConfigElementRepository _configElementRepository; + + public FFmpegReportsHealthCheck(IConfigElementRepository configElementRepository) => + _configElementRepository = configElementRepository; + + public async Task Check() + { + Option saveReports = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegSaveReports); + + foreach (bool value in saveReports) + { + if (value) + { + return Result( + HealthCheckStatus.Warning, + "FFmpeg troubleshooting reports are enabled and may use a lot of disk space"); + } + } + + return OkResult(); + } + + protected override string Title => "FFmpeg Reports"; + } +} diff --git a/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs new file mode 100644 index 000000000..b9a11f2c9 --- /dev/null +++ b/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs @@ -0,0 +1,110 @@ +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Health.Checks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Health.Checks +{ + public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthCheck + { + private readonly IConfigElementRepository _configElementRepository; + + public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository) + { + _configElementRepository = configElementRepository; + } + + public async Task Check() + { + Option maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath); + if (maybeFFmpegPath.IsNone) + { + return FailResult("Unable to locate ffmpeg"); + } + + Option maybeFFprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath); + if (maybeFFprobePath.IsNone) + { + return FailResult("Unable to locate ffprobe"); + } + foreach (ConfigElement ffmpegPath in maybeFFmpegPath) + { + Option maybeVersion = await GetVersion(ffmpegPath.Value); + if (maybeVersion.IsNone) + { + return WarningResult("Unable to determine ffmpeg version"); + } + + foreach (string version in maybeVersion) + { + foreach (HealthCheckResult result in ValidateVersion(version, "ffmpeg")) + { + return result; + } + } + } + + foreach (ConfigElement ffprobePath in maybeFFprobePath) + { + Option maybeVersion = await GetVersion(ffprobePath.Value); + if (maybeVersion.IsNone) + { + return WarningResult("Unable to determine ffprobe version"); + } + + foreach (string version in maybeVersion) + { + foreach (HealthCheckResult result in ValidateVersion(version, "ffprobe")) + { + return result; + } + } + } + + return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty); + } + + private Option ValidateVersion(string version, string app) + { + if (version.StartsWith("3.")) + { + return FailResult($"{app} version {version} is too old; please install 4.3!"); + } + + if (version.StartsWith("4.4")) + { + return FailResult($"{app} version 4.4 is known to have issues; please install 4.3!"); + } + + if (!version.StartsWith("4.3")) + { + return WarningResult($"{app} version {version} is unexpected and may have problems; please install 4.3!"); + } + + return None; + } + + private static async Task> GetVersion(string path) + { + Option maybeLine = await GetProcessOutput(path, new[] { "-version" }) + .Map(s => s.Split("\n").HeadOrNone().Map(h => h.Trim())); + foreach (string line in maybeLine) + { + const string PATTERN = @"version\s+([^\s]+)"; + Match match = Regex.Match(line, PATTERN); + if (match.Success) + { + return match.Groups[1].Value; + } + } + + return None; + } + + protected override string Title => "FFmpeg Version"; + } +} diff --git a/ErsatzTV.Infrastructure/Health/Checks/HardwareAccelerationHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/HardwareAccelerationHealthCheck.cs new file mode 100644 index 000000000..25d2575ef --- /dev/null +++ b/ErsatzTV.Infrastructure/Health/Checks/HardwareAccelerationHealthCheck.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Health.Checks; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Health.Checks +{ + public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAccelerationHealthCheck + { + private readonly IDbContextFactory _dbContextFactory; + private readonly IConfigElementRepository _configElementRepository; + + public HardwareAccelerationHealthCheck( + IDbContextFactory dbContextFactory, + IConfigElementRepository configElementRepository) + { + _dbContextFactory = dbContextFactory; + _configElementRepository = configElementRepository; + } + + public async Task Check() + { + Option maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath); + if (maybeFFmpegPath.IsNone) + { + return FailResult("Unable to locate ffmpeg"); + } + + string version = Assembly.GetEntryAssembly()?.GetCustomAttribute() + ?.InformationalVersion ?? "unknown"; + + var accelerationKinds = new List(); + + if (version.Contains("docker", StringComparison.OrdinalIgnoreCase)) + { + if (version.Contains("nvidia", StringComparison.OrdinalIgnoreCase)) + { + accelerationKinds.Add(HardwareAccelerationKind.Nvenc); + } + else if (version.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) + { + accelerationKinds.Add(HardwareAccelerationKind.Vaapi); + } + } + + if (!accelerationKinds.Any()) + { + accelerationKinds.AddRange(await GetSupportedAccelerationKinds(maybeFFmpegPath.ValueUnsafe().Value)); + } + + if (!accelerationKinds.Any()) + { + return InfoResult("No compatible hardware acceleration kinds are supported by ffmpeg"); + } + + Option maybeResult = await VerifyProfilesUseAcceleration(accelerationKinds); + foreach (HealthCheckResult result in maybeResult) + { + return result; + } + + return OkResult(); + } + + private async Task> VerifyProfilesUseAcceleration( + IEnumerable accelerationKinds) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + + List badChannels = await dbContext.Channels + .Filter(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect) + .Filter(c => !accelerationKinds.Contains(c.FFmpegProfile.HardwareAcceleration)) + .ToListAsync(); + + if (badChannels.Any()) + { + var accel = string.Join(", ", accelerationKinds); + var channels = string.Join(", ", badChannels.Map(c => $"{c.Number} - {c.Name}")); + return WarningResult( + $"The following channels are transcoding without hardware acceleration ({accel}): {channels}"); + } + + return None; + } + + private static async Task> GetSupportedAccelerationKinds(string ffmpegPath) + { + var result = new System.Collections.Generic.HashSet(); + + string output = await GetProcessOutput(ffmpegPath, new[] { "-v", "quiet", "-hwaccels" }); + foreach (string method in output.Split("\n").Map(s => s.Trim()).Skip(1)) + { + switch (method) + { + case "vaapi": + result.Add(HardwareAccelerationKind.Vaapi); + break; + case "nvenc": + result.Add(HardwareAccelerationKind.Nvenc); + break; + case "qsv": + // qsv is only supported on windows + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + result.Add(HardwareAccelerationKind.Qsv); + } + break; + } + } + + return result.ToList(); + } + + protected override string Title => "Hardware Acceleration"; + } +} diff --git a/ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs new file mode 100644 index 000000000..16b1d6fee --- /dev/null +++ b/ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Health.Checks; +using ErsatzTV.Infrastructure.Data; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Health.Checks +{ + public class MovieMetadataHealthCheck : BaseHealthCheck, IMovieMetadataHealthCheck + { + private readonly IDbContextFactory _dbContextFactory; + + public MovieMetadataHealthCheck(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + protected override string Title => "Movie Metadata"; + + public async Task Check() + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + + List movies = await dbContext.Movies + .Filter(e => e.MovieMetadata.Count == 0) + .Include(e => e.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .ToListAsync(); + + if (movies.Any()) + { + var paths = movies.SelectMany(e => e.MediaVersions.Map(mv => mv.MediaFiles)) + .Flatten() + .Map(f => Optional(Path.GetDirectoryName(f.Path))) + .Sequence() + .Flatten() + .Distinct() + .Take(5) + .ToList(); + + var folders = string.Join(", ", paths); + + return WarningResult($"There are {movies.Count} movies with missing metadata, including in the following folders: {folders}"); + } + + return OkResult(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs new file mode 100644 index 000000000..722847df5 --- /dev/null +++ b/ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Health.Checks; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Health.Checks +{ + public class ZeroDurationHealthCheck : BaseHealthCheck, IZeroDurationHealthCheck + { + private readonly IDbContextFactory _dbContextFactory; + + public ZeroDurationHealthCheck(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + protected override string Title => "Zero Duration"; + + public async Task Check() + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + + List episodes = await dbContext.Episodes + .Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero)) + .Include(e => e.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .ToListAsync(); + + List movies = await dbContext.Movies + .Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero)) + .Include(e => e.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .ToListAsync(); + + List all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path) + .Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path)) + .ToList(); + + if (all.Any()) + { + var paths = all.Take(5).ToList(); + + var files = string.Join(", ", paths); + + return WarningResult($"There are {all.Count} files with zero duration, including the following: {files}"); + } + + return OkResult(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Health/HealthCheckService.cs b/ErsatzTV.Infrastructure/Health/HealthCheckService.cs new file mode 100644 index 000000000..ddede362c --- /dev/null +++ b/ErsatzTV.Infrastructure/Health/HealthCheckService.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Health.Checks; +using LanguageExt; + +namespace ErsatzTV.Infrastructure.Health +{ + public class HealthCheckService : IHealthCheckService + { + private readonly List _checks; + + // ReSharper disable SuggestBaseTypeForParameterInConstructor + public HealthCheckService( + IFFmpegVersionHealthCheck ffmpegVersionHealthCheck, + IFFmpegReportsHealthCheck fFmpegReportsHealthCheck, + IHardwareAccelerationHealthCheck hardwareAccelerationHealthCheck, + IMovieMetadataHealthCheck movieMetadataHealthCheck, + IEpisodeMetadataHealthCheck episodeMetadataHealthCheck, + IZeroDurationHealthCheck zeroDurationHealthCheck) + { + _checks = new List + { + ffmpegVersionHealthCheck, + fFmpegReportsHealthCheck, + hardwareAccelerationHealthCheck, + movieMetadataHealthCheck, + episodeMetadataHealthCheck, + zeroDurationHealthCheck + }; + } + + public Task> PerformHealthChecks() => + _checks.Map(c => c.Check()).Sequence().Map(results => results.ToList()); + } +} diff --git a/ErsatzTV/Pages/Index.razor b/ErsatzTV/Pages/Index.razor index 72381cbf2..7b8d955f3 100644 --- a/ErsatzTV/Pages/Index.razor +++ b/ErsatzTV/Pages/Index.razor @@ -1,9 +1,12 @@ @page "/" @using Microsoft.Extensions.Caching.Memory @using System.Reflection +@using ErsatzTV.Application.Health.Queries +@using ErsatzTV.Core.Health @using ErsatzTV.Core.Interfaces.GitHub @inject IGitHubApiClient _gitHubApiClient @inject IMemoryCache _memoryCache +@inject IMediator _mediator @@ -12,11 +15,49 @@ Full changelog is available on GitHub + + + Health Checks + + + Check + Message + + + +
+ @if (context.Status == HealthCheckStatus.Fail) + { + + } + else if (context.Status == HealthCheckStatus.Warning) + { + + } + else if (context.Status == HealthCheckStatus.Info) + { + + } + else + { + + } +
@context.Title
+
+
+ @context.Message +
+
@code { private string _releaseNotes; + private MudTable _table; protected override async Task OnParametersSetAsync() { @@ -66,5 +107,16 @@ // ignore } } + + private async Task> ServerReload(TableState state) + { + List healthCheckResults = await _mediator.Send(new GetAllHealthCheckResults()); + + return new TableData + { + TotalItems = healthCheckResults.Count, + Items = healthCheckResults + }; + } } \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 9e19af0af..cea75ca27 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -11,6 +11,8 @@ using ErsatzTV.Application.Logs.Queries; using ErsatzTV.Core; using ErsatzTV.Core.Emby; using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Health.Checks; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.GitHub; @@ -34,6 +36,8 @@ using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Infrastructure.Emby; using ErsatzTV.Infrastructure.GitHub; +using ErsatzTV.Infrastructure.Health; +using ErsatzTV.Infrastructure.Health.Checks; using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Jellyfin; using ErsatzTV.Infrastructure.Locking; @@ -197,6 +201,14 @@ namespace ErsatzTV AddChannel(services); AddChannel(services); AddChannel(services); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();