diff --git a/CHANGELOG.md b/CHANGELOG.md index 43d8cd0e7..deb3da4c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added - Add search field to filter blocks table +- Show full error/exception details in playback troubleshooting logs +- Add basic free space validation on startup + - ETV will now fail to start with less than 128 MB free space in config or transcode folders +- Add downgrade health check to inform users when they are doing something that WILL impact stability ### Fixed - Do not allow deleting ffmpeg profiles that are used by channels @@ -16,7 +20,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Use table instead of tree view on blocks page -- Show full error/exception details in playback troubleshooting logs ## [25.7.0] - 2025-09-14 ### Added diff --git a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs index d49612039..3c426bbde 100644 --- a/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs @@ -46,247 +46,261 @@ public class RefreshChannelDataHandler : IRequestHandler public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken) { - _logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber); + try + { + _logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber); - _localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder); + _localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder); - string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml"); - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml"); + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - int hiddenCount = await dbContext.Channels - .Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false) - .CountAsync(cancellationToken); - if (hiddenCount > 0) - { - File.Delete(targetFile); - return; - } - - string movieTemplateFileName = GetMovieTemplateFileName(); - string episodeTemplateFileName = GetEpisodeTemplateFileName(); - string musicVideoTemplateFileName = GetMusicVideoTemplateFileName(); - string songTemplateFileName = GetSongTemplateFileName(); - string otherVideoTemplateFileName = GetOtherVideoTemplateFileName(); - if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null || - songTemplateFileName is null || otherVideoTemplateFileName is null) - { - return; - } + int hiddenCount = await dbContext.Channels + .Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false) + .CountAsync(cancellationToken); + if (hiddenCount > 0) + { + File.Delete(targetFile); + return; + } - var minifier = new XmlMinifier( - new XmlMinificationSettings + string movieTemplateFileName = GetMovieTemplateFileName(); + string episodeTemplateFileName = GetEpisodeTemplateFileName(); + string musicVideoTemplateFileName = GetMusicVideoTemplateFileName(); + string songTemplateFileName = GetSongTemplateFileName(); + string otherVideoTemplateFileName = GetOtherVideoTemplateFileName(); + if (movieTemplateFileName is null || episodeTemplateFileName is null || + musicVideoTemplateFileName is null || + songTemplateFileName is null || otherVideoTemplateFileName is null) { - MinifyWhitespace = true, - RemoveXmlComments = true, - CollapseTagsWithoutContent = true - }); + return; + } - var templateContext = new XmlTemplateContext(); + var minifier = new XmlMinifier( + new XmlMinificationSettings + { + MinifyWhitespace = true, + RemoveXmlComments = true, + CollapseTagsWithoutContent = true + }); - string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken); - var movieTemplate = Template.Parse(movieText, movieTemplateFileName); + var templateContext = new XmlTemplateContext(); - string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken); - var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName); + string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken); + var movieTemplate = Template.Parse(movieText, movieTemplateFileName); - string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken); - var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName); + string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken); + var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName); - string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken); - var songTemplate = Template.Parse(songText, songTemplateFileName); + string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken); + var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName); - string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken); - var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName); + string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken); + var songTemplate = Template.Parse(songText, songTemplateFileName); - TimeSpan playoutOffset = TimeSpan.Zero; - string mirrorChannelNumber = null; - Option maybeChannel = await dbContext.Channels - .AsNoTracking() - .Include(c => c.MirrorSourceChannel) - .Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null) - .SelectOneAsync(c => c.Number == request.ChannelNumber, c => c.Number == request.ChannelNumber, cancellationToken); - foreach (Channel channel in maybeChannel) - { - mirrorChannelNumber = channel.MirrorSourceChannel.Number; - playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero; - } + string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken); + var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName); - List playouts = await dbContext.Playouts - .AsNoTracking() - .Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber)) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).EpisodeMetadata) - .ThenInclude(em => em.Guids) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Episode).Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Guids) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Guids) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Studios) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Directors) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) - .ThenInclude(mvm => mvm.Artists) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as MusicVideo).Artist) - .ThenInclude(a => a.ArtistMetadata) - .ThenInclude(am => am.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) - .ThenInclude(vm => vm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Song).SongMetadata) - .ThenInclude(vm => vm.Artwork) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Song).SongMetadata) - .ThenInclude(sm => sm.Genres) - .Include(p => p.Items) - .ThenInclude(i => i.MediaItem) - .ThenInclude(i => (i as Song).SongMetadata) - .ThenInclude(sm => sm.Studios) - .ToListAsync(cancellationToken); - - await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream(); - await using var xml = XmlWriter.Create( - ms, - new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment }); - - int daysToBuild = await _configElementRepository - .GetValue(ConfigElementKey.XmltvDaysToBuild, cancellationToken) - .IfNoneAsync(2); - - DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild); - - foreach (Playout playout in playouts) - { - switch (playout.ScheduleKind) + TimeSpan playoutOffset = TimeSpan.Zero; + string mirrorChannelNumber = null; + Option maybeChannel = await dbContext.Channels + .AsNoTracking() + .Include(c => c.MirrorSourceChannel) + .Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null) + .SelectOneAsync( + c => c.Number == request.ChannelNumber, + c => c.Number == request.ChannelNumber, + cancellationToken); + foreach (Channel channel in maybeChannel) { - case PlayoutScheduleKind.Classic: - case PlayoutScheduleKind.Sequential: - case PlayoutScheduleKind.Scripted: - var floodSorted = playouts - .Collect(p => p.Items) - .OrderBy(pi => pi.Start) - .Filter(pi => pi.StartOffset <= finish) - .ToList(); - foreach (var item in floodSorted) - { - item.Start += playoutOffset; - item.Finish += playoutOffset; - } - await WritePlayoutXml( - request, - floodSorted, - templateContext, - movieTemplate, - episodeTemplate, - musicVideoTemplate, - songTemplate, - otherVideoTemplate, - minifier, - xml, - cancellationToken); - break; - case PlayoutScheduleKind.Block: - var blockSorted = playouts - .Collect(p => p.Items) - .OrderBy(pi => pi.Start) - .Filter(pi => pi.StartOffset <= finish) - .ToList(); - foreach (var item in blockSorted) - { - item.Start += playoutOffset; - item.Finish += playoutOffset; - } - await WriteBlockPlayoutXml( - request, - blockSorted, - templateContext, - movieTemplate, - episodeTemplate, - musicVideoTemplate, - songTemplate, - otherVideoTemplate, - minifier, - xml, - cancellationToken); - break; - case PlayoutScheduleKind.ExternalJson: - var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile)) - .Filter(pi => pi.StartOffset <= finish) - .ToList(); - foreach (var item in externalJsonSorted) - { - item.Start += playoutOffset; - item.Finish += playoutOffset; - } - await WritePlayoutXml( - request, - externalJsonSorted, - templateContext, - movieTemplate, - episodeTemplate, - musicVideoTemplate, - songTemplate, - otherVideoTemplate, - minifier, - xml, - cancellationToken); - break; + mirrorChannelNumber = channel.MirrorSourceChannel.Number; + playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero; } - } - await xml.FlushAsync(); + List playouts = await dbContext.Playouts + .AsNoTracking() + .Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber)) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).EpisodeMetadata) + .ThenInclude(em => em.Guids) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Guids) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Guids) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Studios) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Directors) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Artists) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).Artist) + .ThenInclude(a => a.ArtistMetadata) + .ThenInclude(am => am.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) + .ThenInclude(vm => vm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(vm => vm.Artwork) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(sm => sm.Genres) + .Include(p => p.Items) + .ThenInclude(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(sm => sm.Studios) + .ToListAsync(cancellationToken); + + await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream(); + await using var xml = XmlWriter.Create( + ms, + new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment }); + + int daysToBuild = await _configElementRepository + .GetValue(ConfigElementKey.XmltvDaysToBuild, cancellationToken) + .IfNoneAsync(2); + + DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild); + + foreach (Playout playout in playouts) + { + switch (playout.ScheduleKind) + { + case PlayoutScheduleKind.Classic: + case PlayoutScheduleKind.Sequential: + case PlayoutScheduleKind.Scripted: + var floodSorted = playouts + .Collect(p => p.Items) + .OrderBy(pi => pi.Start) + .Filter(pi => pi.StartOffset <= finish) + .ToList(); + foreach (var item in floodSorted) + { + item.Start += playoutOffset; + item.Finish += playoutOffset; + } - string tempFile = Path.GetTempFileName(); - await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken); + await WritePlayoutXml( + request, + floodSorted, + templateContext, + movieTemplate, + episodeTemplate, + musicVideoTemplate, + songTemplate, + otherVideoTemplate, + minifier, + xml, + cancellationToken); + break; + case PlayoutScheduleKind.Block: + var blockSorted = playouts + .Collect(p => p.Items) + .OrderBy(pi => pi.Start) + .Filter(pi => pi.StartOffset <= finish) + .ToList(); + foreach (var item in blockSorted) + { + item.Start += playoutOffset; + item.Finish += playoutOffset; + } + + await WriteBlockPlayoutXml( + request, + blockSorted, + templateContext, + movieTemplate, + episodeTemplate, + musicVideoTemplate, + songTemplate, + otherVideoTemplate, + minifier, + xml, + cancellationToken); + break; + case PlayoutScheduleKind.ExternalJson: + var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile)) + .Filter(pi => pi.StartOffset <= finish) + .ToList(); + foreach (var item in externalJsonSorted) + { + item.Start += playoutOffset; + item.Finish += playoutOffset; + } - File.Move(tempFile, targetFile, true); + await WritePlayoutXml( + request, + externalJsonSorted, + templateContext, + movieTemplate, + episodeTemplate, + musicVideoTemplate, + songTemplate, + otherVideoTemplate, + minifier, + xml, + cancellationToken); + break; + } + } + + await xml.FlushAsync(); + + string tempFile = Path.GetTempFileName(); + await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken); + + File.Move(tempFile, targetFile, true); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + // do nothing + } } private async Task WritePlayoutXml( diff --git a/ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs b/ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs index ed31b4f97..fdf30bad8 100644 --- a/ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs @@ -21,7 +21,7 @@ namespace ErsatzTV.Application.Libraries; public abstract class CallLibraryScannerHandler { - private readonly int _batchSize = 100; + private const int BatchSize = 100; private readonly ChannelWriter _channel; private readonly IConfigElementRepository _configElementRepository; private readonly IDbContextFactory _dbContextFactory; @@ -55,8 +55,7 @@ public abstract class CallLibraryScannerHandler using var forcefulCts = new CancellationTokenSource(); await using CancellationTokenRegistration link = - cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10)) - ); + cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10))); CommandResult process = await Cli.Wrap(scanner) .WithArguments(arguments) @@ -72,12 +71,14 @@ public abstract class CallLibraryScannerHandler if (_toReindex.Count > 0) { + // ReSharper disable once PossiblyMistakenUseOfCancellationToken await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray()), cancellationToken); _toReindex.Clear(); } if (_toRemove.Count > 0) { + // ReSharper disable once PossiblyMistakenUseOfCancellationToken await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray()), cancellationToken); _toRemove.Clear(); } @@ -139,14 +140,14 @@ public abstract class CallLibraryScannerHandler } _toReindex.AddRange(progressUpdate.ItemsToReindex); - if (_toReindex.Count >= _batchSize) + if (_toReindex.Count >= BatchSize) { await _channel.WriteAsync(new ReindexMediaItems(_toReindex.ToArray())); _toReindex.Clear(); } _toRemove.AddRange(progressUpdate.ItemsToRemove); - if (_toRemove.Count >= _batchSize) + if (_toRemove.Count >= BatchSize) { await _channel.WriteAsync(new RemoveMediaItems(_toReindex.ToArray())); _toRemove.Clear(); @@ -177,41 +178,48 @@ public abstract class CallLibraryScannerHandler protected async Task> Validate(TRequest request, CancellationToken cancellationToken) { - int libraryRefreshInterval = await _configElementRepository - .GetValue(ConfigElementKey.LibraryRefreshInterval, cancellationToken) - .IfNoneAsync(0); + try + { + int libraryRefreshInterval = await _configElementRepository + .GetValue(ConfigElementKey.LibraryRefreshInterval, cancellationToken) + .IfNoneAsync(0); - libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999); + libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999); - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - DateTimeOffset lastScan = await GetLastScan(dbContext, request, cancellationToken); - if (!ScanIsRequired(lastScan, libraryRefreshInterval, request)) - { - return new ScanIsNotRequired(); - } + DateTimeOffset lastScan = await GetLastScan(dbContext, request, cancellationToken); + if (!ScanIsRequired(lastScan, libraryRefreshInterval, request)) + { + return new ScanIsNotRequired(); + } - string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows) - ? "ErsatzTV.Scanner.exe" - : "ErsatzTV.Scanner"; + string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows) + ? "ErsatzTV.Scanner.exe" + : "ErsatzTV.Scanner"; - string processFileName = Environment.ProcessPath ?? string.Empty; - string processExecutable = Path.GetFileNameWithoutExtension(processFileName); - string folderName = Path.GetDirectoryName(processFileName); - if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase)) - { - folderName = AppContext.BaseDirectory; - } + string processFileName = Environment.ProcessPath ?? string.Empty; + string processExecutable = Path.GetFileNameWithoutExtension(processFileName); + string folderName = Path.GetDirectoryName(processFileName); + if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase)) + { + folderName = AppContext.BaseDirectory; + } - if (!string.IsNullOrWhiteSpace(folderName)) - { - string localFileName = Path.Combine(folderName, executable); - if (File.Exists(localFileName)) + if (!string.IsNullOrWhiteSpace(folderName)) { - return localFileName; + string localFileName = Path.Combine(folderName, executable); + if (File.Exists(localFileName)) + { + return localFileName; + } } - } - return BaseError.New("Unable to locate ErsatzTV.Scanner executable"); + return BaseError.New("Unable to locate ErsatzTV.Scanner executable"); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return BaseError.New("Scan was canceled"); + } } } diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj index 47444e74c..942a4db07 100644 --- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj +++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj @@ -22,7 +22,7 @@ - + diff --git a/ErsatzTV.Core/Health/Checks/IDowngradeHealthCheck.cs b/ErsatzTV.Core/Health/Checks/IDowngradeHealthCheck.cs new file mode 100644 index 000000000..4c463732b --- /dev/null +++ b/ErsatzTV.Core/Health/Checks/IDowngradeHealthCheck.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Health.Checks; + +public interface IDowngradeHealthCheck : IHealthCheck +{ +} diff --git a/ErsatzTV.Core/Interfaces/Database/IDatabaseMigrations.cs b/ErsatzTV.Core/Interfaces/Database/IDatabaseMigrations.cs new file mode 100644 index 000000000..63efb1204 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Database/IDatabaseMigrations.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Interfaces.Database; + +public interface IDatabaseMigrations +{ + Task> GetUnknownMigrations(); +} diff --git a/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj b/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj index 459b99776..2be9fe005 100644 --- a/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj +++ b/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj index a6d91391c..06ffb0075 100644 --- a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj +++ b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj @@ -12,7 +12,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/ErsatzTV.Infrastructure/Database/DatabaseMigrations.cs b/ErsatzTV.Infrastructure/Database/DatabaseMigrations.cs new file mode 100644 index 000000000..6125b1bdd --- /dev/null +++ b/ErsatzTV.Infrastructure/Database/DatabaseMigrations.cs @@ -0,0 +1,43 @@ +using ErsatzTV.Core.Interfaces.Database; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Infrastructure.Database; + +public class DatabaseMigrations(IDbContextFactory dbContextFactory, ILogger logger) + : IDatabaseMigrations +{ + private IReadOnlyList _unknownMigrations; + + public async Task> GetUnknownMigrations() + { + if (_unknownMigrations is not null) + { + return _unknownMigrations; + } + + try + { + await using TvContext context = await dbContextFactory.CreateDbContextAsync(); + + IEnumerable appliedMigrations = await context.Database.GetAppliedMigrationsAsync(); + IEnumerable definedMigrations = context.Database.GetMigrations(); + + _unknownMigrations = appliedMigrations.Except(definedMigrations).ToList().AsReadOnly(); + if (_unknownMigrations.Any()) + { + logger.LogCritical( + "Downgrade detected! Database has migrations not known to this version of ErsatzTV: {Migrations}", + _unknownMigrations); + } + } + catch (Exception ex) + { + logger.LogCritical(ex, "Error checking for downgrade-related database migrations"); + _unknownMigrations = []; + } + + return _unknownMigrations; + } +} diff --git a/ErsatzTV.Infrastructure/Health/Checks/DowngradeHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/DowngradeHealthCheck.cs new file mode 100644 index 000000000..0a08031ac --- /dev/null +++ b/ErsatzTV.Infrastructure/Health/Checks/DowngradeHealthCheck.cs @@ -0,0 +1,24 @@ +using ErsatzTV.Core.Health; +using ErsatzTV.Core.Health.Checks; +using ErsatzTV.Core.Interfaces.Database; + +namespace ErsatzTV.Infrastructure.Health.Checks; + +public class DowngradeHealthCheck(IDatabaseMigrations databaseMigrations) : BaseHealthCheck, IDowngradeHealthCheck +{ + public override string Title => "ErsatzTV Downgrade"; + + public async Task Check(CancellationToken cancellationToken) + { + IReadOnlyList unknownMigrations = await databaseMigrations.GetUnknownMigrations(); + if (unknownMigrations.Any()) + { + return FailResult( + "Downgrade detected; THIS IS NOT SUPPORTED AND WILL IMPACT STABILITY", + "Downgrade detected", + new HealthCheckLink("https://ersatztv.org/docs/installation/#downgrading")); + } + + return NotApplicableResult(); + } +} diff --git a/ErsatzTV.Infrastructure/Health/HealthCheckService.cs b/ErsatzTV.Infrastructure/Health/HealthCheckService.cs index 336c37999..cb2b41544 100644 --- a/ErsatzTV.Infrastructure/Health/HealthCheckService.cs +++ b/ErsatzTV.Infrastructure/Health/HealthCheckService.cs @@ -28,6 +28,7 @@ public class HealthCheckService : IHealthCheckService IVaapiDriverHealthCheck vaapiDriverHealthCheck, IErrorReportsHealthCheck errorReportsHealthCheck, IUnifiedDockerHealthCheck unifiedDockerHealthCheck, + IDowngradeHealthCheck downgradeHealthCheck, IMemoryCache memoryCache, IMediator mediator, ILogger logger) @@ -37,6 +38,7 @@ public class HealthCheckService : IHealthCheckService _logger = logger; _checks = [ + downgradeHealthCheck, macOsConfigFolderHealthCheck, unifiedDockerHealthCheck, ffmpegVersionHealthCheck, diff --git a/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj b/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj index 9551d8c08..67c16b410 100644 --- a/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj +++ b/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj @@ -13,7 +13,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/ErsatzTV/Controllers/ArtworkController.cs b/ErsatzTV/Controllers/ArtworkController.cs index 954bbfdd7..f73d716b5 100644 --- a/ErsatzTV/Controllers/ArtworkController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -188,22 +188,29 @@ public class ArtworkController : ControllerBase Left: _ => new NotFoundResult().AsTask(), Right: async r => { - HttpClient client = _httpClientFactory.CreateClient(); - HttpContext.Response.RegisterForDispose(client); - client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken); - - var fullPath = new Uri(r.Uri, transcodePath); - HttpResponseMessage response = await client.GetAsync( - fullPath, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - HttpContext.Response.RegisterForDispose(response); - - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); - - return new FileStreamResult( - stream, - response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + try + { + HttpClient client = _httpClientFactory.CreateClient(); + HttpContext.Response.RegisterForDispose(client); + client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken); + + var fullPath = new Uri(r.Uri, transcodePath); + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + HttpContext.Response.RegisterForDispose(response); + + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return NotFound(); + } }); #endif } @@ -221,21 +228,28 @@ public class ArtworkController : ControllerBase Left: _ => new NotFoundResult().AsTask(), Right: async vm => { - HttpClient client = _httpClientFactory.CreateClient(); - HttpContext.Response.RegisterForDispose(client); - - Url fullPath = JellyfinUrl.ForArtwork(vm.Address, path); - HttpResponseMessage response = await client.GetAsync( - fullPath, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - HttpContext.Response.RegisterForDispose(response); - - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); - - return new FileStreamResult( - stream, - response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + try + { + HttpClient client = _httpClientFactory.CreateClient(); + HttpContext.Response.RegisterForDispose(client); + + Url fullPath = JellyfinUrl.ForArtwork(vm.Address, path); + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + HttpContext.Response.RegisterForDispose(response); + + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return NotFound(); + } }); #endif } @@ -253,21 +267,28 @@ public class ArtworkController : ControllerBase Left: _ => new NotFoundResult().AsTask(), Right: async vm => { - HttpClient client = _httpClientFactory.CreateClient(); - HttpContext.Response.RegisterForDispose(client); - - Url fullPath = EmbyUrl.ForArtwork(vm.Address, path); - HttpResponseMessage response = await client.GetAsync( - fullPath, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - HttpContext.Response.RegisterForDispose(response); - - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); - - return new FileStreamResult( - stream, - response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + try + { + HttpClient client = _httpClientFactory.CreateClient(); + HttpContext.Response.RegisterForDispose(client); + + Url fullPath = EmbyUrl.ForArtwork(vm.Address, path); + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + HttpContext.Response.RegisterForDispose(response); + + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return NotFound(); + } }); #endif } diff --git a/ErsatzTV/Pages/Index.razor b/ErsatzTV/Pages/Index.razor index 518e831d1..ba878006a 100644 --- a/ErsatzTV/Pages/Index.razor +++ b/ErsatzTV/Pages/Index.razor @@ -88,9 +88,18 @@ { foreach (HealthCheckLink link in context.Link) { - - @context.Message - + if (link.Link.StartsWith("http")) + { + + @context.Message + + } + else + { + + @context.Message + + } } } else diff --git a/ErsatzTV/Program.cs b/ErsatzTV/Program.cs index 3680820bc..0d7489fd8 100644 --- a/ErsatzTV/Program.cs +++ b/ErsatzTV/Program.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Runtime.InteropServices; using Destructurama; using ErsatzTV.Core; +using ErsatzTV.Services.Validators; using Serilog; using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; @@ -126,7 +127,17 @@ public class Program try { Environment.SetEnvironmentVariable("DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE", "false"); - await CreateHostBuilder(args).Build().RunAsync(); + + IHost host = CreateHostBuilder(args).Build(); + + // run environment validation and exit on failure + var validator = host.Services.GetRequiredService(); + if (!await validator.Validate()) + { + return 1; + } + + await host.RunAsync(); return 0; } catch (Exception ex) diff --git a/ErsatzTV/Properties/launchSettings.json b/ErsatzTV/Properties/launchSettings.json index b8b4d58b3..30157f5eb 100644 --- a/ErsatzTV/Properties/launchSettings.json +++ b/ErsatzTV/Properties/launchSettings.json @@ -11,4 +11,4 @@ } } } -} \ No newline at end of file +} diff --git a/ErsatzTV/Services/RunOnce/EndpointValidatorService.cs b/ErsatzTV/Services/RunOnce/EndpointValidatorService.cs index d40b9a983..77f89c440 100644 --- a/ErsatzTV/Services/RunOnce/EndpointValidatorService.cs +++ b/ErsatzTV/Services/RunOnce/EndpointValidatorService.cs @@ -2,22 +2,13 @@ namespace ErsatzTV.Services.RunOnce; -public class EndpointValidatorService : BackgroundService +public class EndpointValidatorService(ILogger logger) : BackgroundService { - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public EndpointValidatorService(IConfiguration configuration, ILogger logger) - { - _configuration = configuration; - _logger = logger; - } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Yield(); - _logger.LogInformation( + logger.LogInformation( "Server will listen on streaming port {StreamingPort}, UI port {UiPort} - try UI at {UI}", Settings.StreamingPort, Settings.UiPort, diff --git a/ErsatzTV/Services/Validators/EnvironmentValidator.cs b/ErsatzTV/Services/Validators/EnvironmentValidator.cs new file mode 100644 index 000000000..026c661d3 --- /dev/null +++ b/ErsatzTV/Services/Validators/EnvironmentValidator.cs @@ -0,0 +1,48 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Services.Validators; + +public class EnvironmentValidator(ILogger logger) + : IEnvironmentValidator +{ + private const long OneHundredTwentyEightMegabytes = 128000000; + + public Task Validate() + { + long configFreeSpace = long.MaxValue; + long transcodeFreeSpace = long.MaxValue; + + try + { + var configDriveInfo = new DriveInfo(FileSystemLayout.AppDataFolder); + configFreeSpace = configDriveInfo.AvailableFreeSpace; + + var transcodeDriveInfo = new DriveInfo(FileSystemLayout.TranscodeFolder); + transcodeFreeSpace = transcodeDriveInfo.AvailableFreeSpace; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to validate available free space"); + } + + if (configFreeSpace < OneHundredTwentyEightMegabytes) + { + logger.LogCritical( + "ErsatzTV requires at least 128 MB of free space at {ConfigFolder}", + FileSystemLayout.AppDataFolder); + + return Task.FromResult(false); + } + + if (transcodeFreeSpace < OneHundredTwentyEightMegabytes) + { + logger.LogCritical( + "ErsatzTV requires at least 128 MB of free space at {TranscodeFolder}", + FileSystemLayout.TranscodeFolder); + + return Task.FromResult(false); + } + + return Task.FromResult(true); + } +} diff --git a/ErsatzTV/Services/Validators/IEnvironmentValidator.cs b/ErsatzTV/Services/Validators/IEnvironmentValidator.cs new file mode 100644 index 000000000..fb48e2e98 --- /dev/null +++ b/ErsatzTV/Services/Validators/IEnvironmentValidator.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Services.Validators; + +public interface IEnvironmentValidator +{ + Task Validate(); +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 7dc9e2f4a..91bf70ca4 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -18,6 +18,7 @@ using ErsatzTV.Core.Graphics; using ErsatzTV.Core.Health; using ErsatzTV.Core.Health.Checks; using ErsatzTV.Core.Images; +using ErsatzTV.Core.Interfaces.Database; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.GitHub; @@ -53,6 +54,7 @@ using ErsatzTV.Formatters; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Infrastructure.Data.Repositories.Caching; +using ErsatzTV.Infrastructure.Database; using ErsatzTV.Infrastructure.Emby; using ErsatzTV.Infrastructure.FFmpeg; using ErsatzTV.Infrastructure.GitHub; @@ -74,6 +76,7 @@ using ErsatzTV.Infrastructure.Trakt; using ErsatzTV.Serialization; using ErsatzTV.Services; using ErsatzTV.Services.RunOnce; +using ErsatzTV.Services.Validators; using FluentValidation; using FluentValidation.AspNetCore; using Ganss.Xss; @@ -673,6 +676,9 @@ public class Startup private static void CustomServices(IServiceCollection services) { + services.AddSingleton(); + + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // TODO: does this need to be singleton? services.AddSingleton(); @@ -725,6 +731,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();