Browse Source

cleanup some exceptions; add health check (#2495)

* handle artwork timeouts so they aren't reported

* catch some more cancellation errors

* add free space validation on startup

* add downgrade health check

* update dependencies
pull/2496/head
Jason Dove 3 months ago committed by GitHub
parent
commit
9016523757
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 460
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  3. 72
      ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs
  4. 2
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  5. 5
      ErsatzTV.Core/Health/Checks/IDowngradeHealthCheck.cs
  6. 6
      ErsatzTV.Core/Interfaces/Database/IDatabaseMigrations.cs
  7. 2
      ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
  8. 2
      ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
  9. 43
      ErsatzTV.Infrastructure/Database/DatabaseMigrations.cs
  10. 24
      ErsatzTV.Infrastructure/Health/Checks/DowngradeHealthCheck.cs
  11. 2
      ErsatzTV.Infrastructure/Health/HealthCheckService.cs
  12. 2
      ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
  13. 113
      ErsatzTV/Controllers/ArtworkController.cs
  14. 15
      ErsatzTV/Pages/Index.razor
  15. 13
      ErsatzTV/Program.cs
  16. 2
      ErsatzTV/Properties/launchSettings.json
  17. 13
      ErsatzTV/Services/RunOnce/EndpointValidatorService.cs
  18. 48
      ErsatzTV/Services/Validators/EnvironmentValidator.cs
  19. 6
      ErsatzTV/Services/Validators/IEnvironmentValidator.cs
  20. 7
      ErsatzTV/Startup.cs

5
CHANGELOG.md

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

460
ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs

@ -46,247 +46,261 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -46,247 +46,261 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
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<Channel> 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<Playout> 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<int>(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<Channel> 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<Playout> 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<int>(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(

72
ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs

@ -21,7 +21,7 @@ namespace ErsatzTV.Application.Libraries; @@ -21,7 +21,7 @@ namespace ErsatzTV.Application.Libraries;
public abstract class CallLibraryScannerHandler<TRequest>
{
private readonly int _batchSize = 100;
private const int BatchSize = 100;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -55,8 +55,7 @@ public abstract class CallLibraryScannerHandler<TRequest> @@ -55,8 +55,7 @@ public abstract class CallLibraryScannerHandler<TRequest>
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<TRequest> @@ -72,12 +71,14 @@ public abstract class CallLibraryScannerHandler<TRequest>
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<TRequest> @@ -139,14 +140,14 @@ public abstract class CallLibraryScannerHandler<TRequest>
}
_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<TRequest> @@ -177,41 +178,48 @@ public abstract class CallLibraryScannerHandler<TRequest>
protected async Task<Validation<BaseError, string>> Validate(TRequest request, CancellationToken cancellationToken)
{
int libraryRefreshInterval = await _configElementRepository
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
.IfNoneAsync(0);
try
{
int libraryRefreshInterval = await _configElementRepository
.GetValue<int>(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");
}
}
}

2
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -22,7 +22,7 @@ @@ -22,7 +22,7 @@
</PackageReference>
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.2" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />

5
ErsatzTV.Core/Health/Checks/IDowngradeHealthCheck.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Health.Checks;
public interface IDowngradeHealthCheck : IHealthCheck
{
}

6
ErsatzTV.Core/Interfaces/Database/IDatabaseMigrations.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Interfaces.Database;
public interface IDatabaseMigrations
{
Task<IReadOnlyList<string>> GetUnknownMigrations();
}

2
ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
<PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />

2
ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.10.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

43
ErsatzTV.Infrastructure/Database/DatabaseMigrations.cs

@ -0,0 +1,43 @@ @@ -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<TvContext> dbContextFactory, ILogger<DatabaseMigrations> logger)
: IDatabaseMigrations
{
private IReadOnlyList<string> _unknownMigrations;
public async Task<IReadOnlyList<string>> GetUnknownMigrations()
{
if (_unknownMigrations is not null)
{
return _unknownMigrations;
}
try
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync();
IEnumerable<string> appliedMigrations = await context.Database.GetAppliedMigrationsAsync();
IEnumerable<string> 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;
}
}

24
ErsatzTV.Infrastructure/Health/Checks/DowngradeHealthCheck.cs

@ -0,0 +1,24 @@ @@ -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<HealthCheckResult> Check(CancellationToken cancellationToken)
{
IReadOnlyList<string> 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();
}
}

2
ErsatzTV.Infrastructure/Health/HealthCheckService.cs

@ -28,6 +28,7 @@ public class HealthCheckService : IHealthCheckService @@ -28,6 +28,7 @@ public class HealthCheckService : IHealthCheckService
IVaapiDriverHealthCheck vaapiDriverHealthCheck,
IErrorReportsHealthCheck errorReportsHealthCheck,
IUnifiedDockerHealthCheck unifiedDockerHealthCheck,
IDowngradeHealthCheck downgradeHealthCheck,
IMemoryCache memoryCache,
IMediator mediator,
ILogger<HealthCheckService> logger)
@ -37,6 +38,7 @@ public class HealthCheckService : IHealthCheckService @@ -37,6 +38,7 @@ public class HealthCheckService : IHealthCheckService
_logger = logger;
_checks =
[
downgradeHealthCheck,
macOsConfigFolderHealthCheck,
unifiedDockerHealthCheck,
ffmpegVersionHealthCheck,

2
ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj

@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.10.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

113
ErsatzTV/Controllers/ArtworkController.cs

@ -188,22 +188,29 @@ public class ArtworkController : ControllerBase @@ -188,22 +188,29 @@ public class ArtworkController : ControllerBase
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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 @@ -221,21 +228,28 @@ public class ArtworkController : ControllerBase
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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 @@ -253,21 +267,28 @@ public class ArtworkController : ControllerBase
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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
}

15
ErsatzTV/Pages/Index.razor

@ -88,9 +88,18 @@ @@ -88,9 +88,18 @@
{
foreach (HealthCheckLink link in context.Link)
{
<MudLink Href="@link.Link">
@context.Message
</MudLink>
if (link.Link.StartsWith("http"))
{
<MudLink Href="@link.Link" Target="_blank">
@context.Message
</MudLink>
}
else
{
<MudLink Href="@link.Link">
@context.Message
</MudLink>
}
}
}
else

13
ErsatzTV/Program.cs

@ -3,6 +3,7 @@ using System.Globalization; @@ -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 @@ -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<IEnvironmentValidator>();
if (!await validator.Validate())
{
return 1;
}
await host.RunAsync();
return 0;
}
catch (Exception ex)

2
ErsatzTV/Properties/launchSettings.json

@ -11,4 +11,4 @@ @@ -11,4 +11,4 @@
}
}
}
}
}

13
ErsatzTV/Services/RunOnce/EndpointValidatorService.cs

@ -2,22 +2,13 @@ @@ -2,22 +2,13 @@
namespace ErsatzTV.Services.RunOnce;
public class EndpointValidatorService : BackgroundService
public class EndpointValidatorService(ILogger<EndpointValidatorService> logger) : BackgroundService
{
private readonly IConfiguration _configuration;
private readonly ILogger<EndpointValidatorService> _logger;
public EndpointValidatorService(IConfiguration configuration, ILogger<EndpointValidatorService> 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,

48
ErsatzTV/Services/Validators/EnvironmentValidator.cs

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
using ErsatzTV.Core;
namespace ErsatzTV.Services.Validators;
public class EnvironmentValidator(ILogger<EnvironmentValidator> logger)
: IEnvironmentValidator
{
private const long OneHundredTwentyEightMegabytes = 128000000;
public Task<bool> 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);
}
}

6
ErsatzTV/Services/Validators/IEnvironmentValidator.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Services.Validators;
public interface IEnvironmentValidator
{
Task<bool> Validate();
}

7
ErsatzTV/Startup.cs

@ -18,6 +18,7 @@ using ErsatzTV.Core.Graphics; @@ -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; @@ -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; @@ -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 @@ -673,6 +676,9 @@ public class Startup
private static void CustomServices(IServiceCollection services)
{
services.AddSingleton<IEnvironmentValidator, EnvironmentValidator>();
services.AddSingleton<IDatabaseMigrations, DatabaseMigrations>();
services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
services.AddSingleton<ITraktApiClient, TraktApiClient>();
@ -725,6 +731,7 @@ public class Startup @@ -725,6 +731,7 @@ public class Startup
services.AddScoped<IVaapiDriverHealthCheck, VaapiDriverHealthCheck>();
services.AddScoped<IErrorReportsHealthCheck, ErrorReportsHealthCheck>();
services.AddScoped<IUnifiedDockerHealthCheck, UnifiedDockerHealthCheck>();
services.AddScoped<IDowngradeHealthCheck, DowngradeHealthCheck>();
services.AddScoped<IHealthCheckService, HealthCheckService>();
services.AddScoped<IChannelRepository, ChannelRepository>();

Loading…
Cancel
Save