Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca8bcacbd3 | ||
|
|
f27286d1dd | ||
|
|
23870b75f7 | ||
|
|
7f5a91c643 | ||
|
|
f1f50e883c | ||
|
|
7506f49f5b | ||
|
|
944f1e4307 | ||
|
|
f7de9ac5ea | ||
|
|
1eb51ad2f4 | ||
|
|
c3e0aaf0b7 |
@@ -1,71 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '30 3 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'csharp' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
+23
-2
@@ -5,6 +5,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.59-alpha] - 2021-09-18
|
||||
### 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
|
||||
- `VAAPI Driver` checks whether a vaapi driver preference is configured when using the vaapi docker image
|
||||
- Add setting to each playout to schedule an automatic daily rebuild
|
||||
- This is useful if the playout uses a smart collection with `released_onthisday`
|
||||
|
||||
### Fixed
|
||||
- Fix docker vaapi support for newer Intel platforms (Gen 8+)
|
||||
- This includes a new setting to force a particular vaapi driver (`iHD` or `i965`), as some Gen 8 or 9 hardware that is supported by both drivers will perform better with one or the other
|
||||
- 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)
|
||||
- Fix "direct play" in MPEG-TS mode (copy audio and video stream when `Transcode` is unchecked)
|
||||
|
||||
## [0.0.58-alpha] - 2021-09-15
|
||||
### Added
|
||||
- Add `released_notinthelast` search field for relative release date queries
|
||||
@@ -594,8 +614,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...HEAD
|
||||
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.57-alpha
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...HEAD
|
||||
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
|
||||
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.58-alpha
|
||||
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
|
||||
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
|
||||
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
|
||||
|
||||
@@ -37,17 +37,17 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
p.Transcode = update.Transcode;
|
||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.NormalizeVideo = update.NormalizeVideo;
|
||||
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
|
||||
p.VideoCodec = update.VideoCodec;
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.AudioCodec = update.AudioCodec;
|
||||
p.AudioBitrate = update.AudioBitrate;
|
||||
p.AudioBufferSize = update.AudioBufferSize;
|
||||
p.NormalizeLoudness = update.NormalizeLoudness;
|
||||
p.NormalizeLoudness = update.Transcode && update.NormalizeLoudness;
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeAudio = update.NormalizeAudio;
|
||||
p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return new UpdateFFmpegProfileResult(p.Id);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,10 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||
}
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegVaapiDriver,
|
||||
(int)request.Settings.VaapiDriver);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace ErsatzTV.Application.FFmpegProfiles
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles
|
||||
{
|
||||
public class FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -8,5 +10,6 @@
|
||||
public string PreferredLanguageCode { get; set; }
|
||||
public bool SaveReports { get; set; }
|
||||
public int? GlobalWatermarkId { get; set; }
|
||||
public VaapiDriver VaapiDriver { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -28,6 +29,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
|
||||
Option<int> watermark =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||
Option<int> vaapiDriver =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver);
|
||||
|
||||
var result = new FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -36,6 +39,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
|
||||
SaveReports = await saveReports.IfNoneAsync(false),
|
||||
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
|
||||
VaapiDriver = (VaapiDriver)await vaapiDriver.IfNoneAsync(0)
|
||||
};
|
||||
|
||||
foreach (int watermarkId in watermark)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Health;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Health.Queries
|
||||
{
|
||||
public record GetAllHealthCheckResults : IRequest<List<HealthCheckResult>>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Health;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Health.Queries
|
||||
{
|
||||
public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheckResults, List<HealthCheckResult>>
|
||||
{
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
|
||||
public GetAllHealthCheckResultsHandler(IHealthCheckService healthCheckService) =>
|
||||
_healthCheckService = healthCheckService;
|
||||
|
||||
public async Task<List<HealthCheckResult>> Handle(
|
||||
GetAllHealthCheckResults request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks();
|
||||
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts.Commands
|
||||
{
|
||||
public record UpdatePlayout
|
||||
(int PlayoutId, Option<TimeSpan> DailyRebuildTime) : IRequest<Either<BaseError, PlayoutNameViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts.Commands
|
||||
{
|
||||
public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseError, PlayoutNameViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public UpdatePlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
|
||||
UpdatePlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
}
|
||||
|
||||
private static async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
UpdatePlayout request,
|
||||
Playout playout)
|
||||
{
|
||||
playout.DailyRebuildTime = null;
|
||||
|
||||
foreach (TimeSpan dailyRebuildTime in request.DailyRebuildTime)
|
||||
{
|
||||
playout.DailyRebuildTime = dailyRebuildTime;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return new PlayoutNameViewModel(
|
||||
playout.Id,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.ProgramSchedule.Name,
|
||||
Optional(playout.DailyRebuildTime));
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, UpdatePlayout request) =>
|
||||
PlayoutMustExist(dbContext, request);
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
|
||||
TvContext dbContext,
|
||||
UpdatePlayout updatePlayout) =>
|
||||
dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
namespace ErsatzTV.Application.Playouts
|
||||
using System;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts
|
||||
{
|
||||
public record PlayoutNameViewModel(
|
||||
int PlayoutId,
|
||||
string ChannelName,
|
||||
string ChannelNumber,
|
||||
string ScheduleName);
|
||||
string ScheduleName,
|
||||
Option<TimeSpan> DailyRebuildTime);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts.Queries
|
||||
{
|
||||
@@ -21,7 +22,13 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Playouts
|
||||
.Filter(p => p.Channel != null && p.ProgramSchedule != null)
|
||||
.Map(p => new PlayoutNameViewModel(p.Id, p.Channel.Name, p.Channel.Number, p.ProgramSchedule.Name))
|
||||
.Map(
|
||||
p => new PlayoutNameViewModel(
|
||||
p.Id,
|
||||
p.Channel.Name,
|
||||
p.Channel.Number,
|
||||
p.ProgramSchedule.Name,
|
||||
Optional(p.DailyRebuildTime)))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -99,6 +99,10 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
watermarkId => dbContext.ChannelWatermarks
|
||||
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
|
||||
|
||||
Option<VaapiDriver> maybeVaapiDriver = await dbContext.ConfigElements
|
||||
.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver)
|
||||
.MapT(i => (VaapiDriver)i);
|
||||
|
||||
return Right<BaseError, Process>(
|
||||
await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
@@ -108,7 +112,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
playoutItemWithPath.Path,
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
now,
|
||||
maybeGlobalWatermark));
|
||||
maybeGlobalWatermark,
|
||||
maybeVaapiDriver));
|
||||
},
|
||||
async error =>
|
||||
{
|
||||
|
||||
@@ -258,6 +258,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
@@ -283,6 +284,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1280, Height = 720 }
|
||||
};
|
||||
@@ -309,6 +311,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
@@ -334,6 +337,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = false,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
@@ -359,6 +363,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "testCodec"
|
||||
@@ -387,6 +392,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "testCodec"
|
||||
@@ -416,6 +422,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "testCodec"
|
||||
@@ -444,6 +451,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264"
|
||||
@@ -473,6 +481,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = false,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264"
|
||||
@@ -495,12 +504,47 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetCopyVideoCodec_AndCopyAudioCodec_When_NotTranscoding_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = false,
|
||||
NormalizeVideo = true,
|
||||
NormalizeAudio = true,
|
||||
NormalizeLoudness = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264"
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
actual.NormalizeLoudness.Should().BeFalse();
|
||||
actual.AudioCodec.Should().Be("copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetVideoBitrate_When_ContentIsPadded_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBitrate = 2525
|
||||
@@ -528,6 +572,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBitrate = 2525
|
||||
@@ -556,6 +601,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBufferSize = 2525
|
||||
@@ -584,6 +630,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBufferSize = 2525
|
||||
@@ -612,6 +659,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
@@ -658,6 +706,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
@@ -681,6 +730,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
@@ -704,6 +754,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioBitrate = 2424,
|
||||
AudioCodec = "ac3"
|
||||
@@ -728,6 +779,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioBufferSize = 2424,
|
||||
AudioCodec = "ac3"
|
||||
@@ -752,6 +804,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "ac3",
|
||||
AudioChannels = 6
|
||||
@@ -776,6 +829,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "ac3",
|
||||
AudioSampleRate = 48
|
||||
@@ -800,6 +854,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioChannels = 6
|
||||
};
|
||||
@@ -823,6 +878,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioSampleRate = 48
|
||||
};
|
||||
@@ -846,6 +902,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioSampleRate = 48,
|
||||
AudioCodec = "ac3"
|
||||
@@ -870,6 +927,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
NormalizeLoudness = true
|
||||
};
|
||||
@@ -893,6 +951,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = false,
|
||||
NormalizeLoudness = true
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
||||
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
|
||||
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
|
||||
public static ConfigElementKey FFmpegVaapiDriver => new("ffmpeg.vaapi_driver");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Diagnostics;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
[DebuggerDisplay("{EpisodeMetadata[0].Title}")]
|
||||
[DebuggerDisplay("{EpisodeMetadata[0].Title ?? \"[unknown episode]\"}")]
|
||||
public class Episode : MediaItem
|
||||
{
|
||||
public int SeasonId { get; set; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
@@ -13,5 +14,6 @@ namespace ErsatzTV.Core.Domain
|
||||
public List<PlayoutItem> Items { get; set; }
|
||||
public PlayoutAnchor Anchor { get; set; }
|
||||
public List<PlayoutProgramScheduleAnchor> ProgramScheduleAnchors { get; set; }
|
||||
public TimeSpan? DailyRebuildTime { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +85,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(version);
|
||||
if (ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
|
||||
{
|
||||
result.PadToDesiredResolution = true;
|
||||
}
|
||||
|
||||
if (ffmpegProfile.NormalizeVideo)
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo)
|
||||
{
|
||||
result.VideoTrackTimeScale = 90000;
|
||||
}
|
||||
@@ -107,7 +107,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.VideoCodec = "copy";
|
||||
}
|
||||
|
||||
if (ffmpegProfile.NormalizeAudio)
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
|
||||
{
|
||||
result.AudioCodec = ffmpegProfile.AudioCodec;
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
@@ -152,7 +152,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
};
|
||||
|
||||
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
|
||||
ffmpegProfile.NormalizeVideo &&
|
||||
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo &&
|
||||
IsIncorrectSize(ffmpegProfile.Resolution, version) ||
|
||||
IsTooLarge(ffmpegProfile.Resolution, version) ||
|
||||
IsOddSize(version);
|
||||
@@ -170,7 +170,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
version.Height % 2 == 1 || version.Width % 2 == 1;
|
||||
|
||||
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaStream videoStream) =>
|
||||
ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
|
||||
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
|
||||
|
||||
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
|
||||
{
|
||||
|
||||
@@ -43,6 +43,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private readonly bool _saveReports;
|
||||
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
|
||||
private bool _isConcat;
|
||||
private VaapiDriver _vaapiDriver;
|
||||
private HardwareAccelerationKind _hwAccel;
|
||||
|
||||
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
|
||||
{
|
||||
@@ -50,6 +52,16 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_saveReports = saveReports;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithVaapiDriver(Option<VaapiDriver> maybeVaapiDriver)
|
||||
{
|
||||
foreach (VaapiDriver vaapiDriver in maybeVaapiDriver)
|
||||
{
|
||||
_vaapiDriver = vaapiDriver;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithThreads(int threads)
|
||||
{
|
||||
_arguments.Add("-threads");
|
||||
@@ -59,6 +71,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel)
|
||||
{
|
||||
_hwAccel = hwAccel;
|
||||
|
||||
switch (hwAccel)
|
||||
{
|
||||
case HardwareAccelerationKind.Qsv:
|
||||
@@ -433,6 +447,19 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
StandardOutputEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
if (_hwAccel == HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
switch (_vaapiDriver)
|
||||
{
|
||||
case VaapiDriver.i965:
|
||||
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "i965");
|
||||
break;
|
||||
case VaapiDriver.iHD:
|
||||
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "iHD");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (_saveReports)
|
||||
{
|
||||
string fileName = _isConcat
|
||||
|
||||
@@ -34,7 +34,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string path,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset now,
|
||||
Option<ChannelWatermark> globalWatermark)
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
Option<VaapiDriver> maybeVaapiDriver)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
|
||||
@@ -58,6 +59,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
|
||||
.WithVaapiDriver(maybeVaapiDriver)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
@@ -160,7 +162,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
private WatermarkOptions GetWatermarkOptions(Channel channel, Option<ChannelWatermark> globalWatermark)
|
||||
{
|
||||
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
|
||||
channel.FFmpegProfile.NormalizeVideo)
|
||||
{
|
||||
// check for channel watermark
|
||||
if (channel.Watermark != null)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public enum VaapiDriver
|
||||
{
|
||||
Default = 0,
|
||||
iHD = 1,
|
||||
i965 = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IEpisodeMetadataHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IFFmpegReportsHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IFFmpegVersionHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IHardwareAccelerationHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IMovieMetadataHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IVaapiDriverHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Health.Checks
|
||||
{
|
||||
public interface IZeroDurationHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.Health
|
||||
{
|
||||
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Health
|
||||
{
|
||||
public enum HealthCheckStatus
|
||||
{
|
||||
Pass = 0,
|
||||
Fail = 1,
|
||||
Warning = 2,
|
||||
Info = 3,
|
||||
NotApplicable = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ErsatzTV.Core.Health
|
||||
{
|
||||
public interface IHealthCheck
|
||||
{
|
||||
Task<HealthCheckResult> Check();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ErsatzTV.Core.Health
|
||||
{
|
||||
public interface IHealthCheckService
|
||||
{
|
||||
Task<List<HealthCheckResult>> PerformHealthChecks();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
string fileName = Path.GetFileName(showFolder);
|
||||
var metadata = new ShowMetadata
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? showFolder };
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? showFolder,
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>()
|
||||
};
|
||||
return GetTelevisionShowMetadata(fileName, metadata);
|
||||
}
|
||||
|
||||
@@ -33,7 +40,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
var baseMetadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
Title = Path.GetFileNameWithoutExtension(path) ?? path,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
EpisodeNumber = 0,
|
||||
Actors = new List<Actor>(),
|
||||
@@ -54,7 +61,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileName(path);
|
||||
var metadata = new MovieMetadata { MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
|
||||
var metadata = new MovieMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = Path.GetFileNameWithoutExtension(path) ?? path,
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>(),
|
||||
Directors = new List<Director>(),
|
||||
Writers = new List<Writer>()
|
||||
};
|
||||
|
||||
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
|
||||
}
|
||||
@@ -128,6 +145,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
EpisodeNumber = episodeNumber,
|
||||
DateAdded = baseMetadata.DateAdded,
|
||||
DateUpdated = baseMetadata.DateAdded,
|
||||
Title = baseMetadata.Title,
|
||||
Actors = new List<Actor>(),
|
||||
Artwork = new List<Artwork>(),
|
||||
Directors = new List<Director>(),
|
||||
@@ -164,12 +182,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata.Title = match.Groups[1].Value.Trim();
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Directors = new List<Director>();
|
||||
metadata.Writers = new List<Writer>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -214,10 +226,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +103,9 @@ namespace ErsatzTV.Core.Scheduling
|
||||
|
||||
var collectionMediaItems = Map.createRange(tuples);
|
||||
|
||||
// using IDisposable scope = _logger.BeginScope(new { PlayoutId = playout.Id });
|
||||
_logger.LogDebug(
|
||||
$"{(rebuild ? "Rebuilding" : "Building")} playout {{PlayoutId}} for channel {{ChannelNumber}} - {{ChannelName}}",
|
||||
"{Action} playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}",
|
||||
rebuild ? "Rebuilding" : "Building",
|
||||
playout.Id,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name);
|
||||
|
||||
@@ -144,7 +144,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
@"SELECT m1.ShowId
|
||||
FROM ShowMetadata m1
|
||||
LEFT OUTER JOIN ShowMetadata m2 ON m2.ShowId = @ShowId
|
||||
WHERE m1.Title = m2.Title AND m1.Year = m2.Year",
|
||||
WHERE m1.Title = m2.Title AND m1.Year is m2.Year",
|
||||
new { ShowId = televisionShowId })
|
||||
.Map(results => results.ToList());
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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 NotApplicableResult() =>
|
||||
new(Title, HealthCheckStatus.NotApplicable, string.Empty);
|
||||
|
||||
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<string> GetProcessOutput(string path, IEnumerable<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TvContext> _dbContextFactory;
|
||||
|
||||
public EpisodeMetadataHealthCheck(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
protected override string Title => "Episode Metadata";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
List<Episode> 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<string>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HealthCheckResult> Check()
|
||||
{
|
||||
Option<bool> saveReports =
|
||||
await _configElementRepository.GetValue<bool>(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";
|
||||
}
|
||||
}
|
||||
@@ -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<HealthCheckResult> Check()
|
||||
{
|
||||
Option<ConfigElement> maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
|
||||
if (maybeFFmpegPath.IsNone)
|
||||
{
|
||||
return FailResult("Unable to locate ffmpeg");
|
||||
}
|
||||
|
||||
Option<ConfigElement> maybeFFprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath);
|
||||
if (maybeFFprobePath.IsNone)
|
||||
{
|
||||
return FailResult("Unable to locate ffprobe");
|
||||
}
|
||||
foreach (ConfigElement ffmpegPath in maybeFFmpegPath)
|
||||
{
|
||||
Option<string> 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<string> 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<HealthCheckResult> 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<Option<string>> GetVersion(string path)
|
||||
{
|
||||
Option<string> 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";
|
||||
}
|
||||
}
|
||||
@@ -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<TvContext> _dbContextFactory;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public HardwareAccelerationHealthCheck(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
{
|
||||
Option<ConfigElement> maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
|
||||
if (maybeFFmpegPath.IsNone)
|
||||
{
|
||||
return FailResult("Unable to locate ffmpeg");
|
||||
}
|
||||
|
||||
string version = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion ?? "unknown";
|
||||
|
||||
var accelerationKinds = new List<HardwareAccelerationKind>();
|
||||
|
||||
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<HealthCheckResult> maybeResult = await VerifyProfilesUseAcceleration(accelerationKinds);
|
||||
foreach (HealthCheckResult result in maybeResult)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return OkResult();
|
||||
}
|
||||
|
||||
private async Task<Option<HealthCheckResult>> VerifyProfilesUseAcceleration(
|
||||
IEnumerable<HardwareAccelerationKind> accelerationKinds)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
List<Channel> 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<List<HardwareAccelerationKind>> GetSupportedAccelerationKinds(string ffmpegPath)
|
||||
{
|
||||
var result = new System.Collections.Generic.HashSet<HardwareAccelerationKind>();
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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<TvContext> _dbContextFactory;
|
||||
|
||||
public MovieMetadataHealthCheck(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
protected override string Title => "Movie Metadata";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
List<Movie> 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<string>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Health;
|
||||
using ErsatzTV.Core.Health.Checks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Health.Checks
|
||||
{
|
||||
public class VaapiDriverHealthCheck : BaseHealthCheck, IVaapiDriverHealthCheck
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public VaapiDriverHealthCheck(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
protected override string Title => "VAAPI Driver";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
{
|
||||
string version = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion ?? "unknown";
|
||||
|
||||
if (!version.Contains("docker", StringComparison.OrdinalIgnoreCase) ||
|
||||
!version.Contains("vaapi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NotApplicableResult();
|
||||
}
|
||||
|
||||
Option<int> maybeVaapiDriver =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver);
|
||||
var vaapiDriver = (VaapiDriver)await maybeVaapiDriver.IfNoneAsync(0);
|
||||
if (vaapiDriver == VaapiDriver.Default)
|
||||
{
|
||||
return InfoResult(
|
||||
"Settings > FFmpeg Settings > VAAPI Driver is set to Default; selecting iHD (Gen 8+) or i965 (up to Gen 9) may offer better performance");
|
||||
}
|
||||
|
||||
return OkResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TvContext> _dbContextFactory;
|
||||
|
||||
public ZeroDurationHealthCheck(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
protected override string Title => "Zero Duration";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
List<Episode> episodes = await dbContext.Episodes
|
||||
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
List<Movie> movies = await dbContext.Movies
|
||||
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
List<string> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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<IHealthCheck> _checks;
|
||||
|
||||
// ReSharper disable SuggestBaseTypeForParameterInConstructor
|
||||
public HealthCheckService(
|
||||
IFFmpegVersionHealthCheck ffmpegVersionHealthCheck,
|
||||
IFFmpegReportsHealthCheck fFmpegReportsHealthCheck,
|
||||
IHardwareAccelerationHealthCheck hardwareAccelerationHealthCheck,
|
||||
IMovieMetadataHealthCheck movieMetadataHealthCheck,
|
||||
IEpisodeMetadataHealthCheck episodeMetadataHealthCheck,
|
||||
IZeroDurationHealthCheck zeroDurationHealthCheck,
|
||||
IVaapiDriverHealthCheck vaapiDriverHealthCheck)
|
||||
{
|
||||
_checks = new List<IHealthCheck>
|
||||
{
|
||||
ffmpegVersionHealthCheck,
|
||||
fFmpegReportsHealthCheck,
|
||||
hardwareAccelerationHealthCheck,
|
||||
movieMetadataHealthCheck,
|
||||
episodeMetadataHealthCheck,
|
||||
zeroDurationHealthCheck,
|
||||
vaapiDriverHealthCheck
|
||||
};
|
||||
}
|
||||
|
||||
public Task<List<HealthCheckResult>> PerformHealthChecks() =>
|
||||
_checks.Map(c => c.Check()).Sequence().Map(results => results.ToList());
|
||||
}
|
||||
}
|
||||
+3153
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_PlayoutDailyRebuildTime : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<TimeSpan>(
|
||||
name: "DailyRebuildTime",
|
||||
table: "Playout",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DailyRebuildTime",
|
||||
table: "Playout");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.9");
|
||||
.HasAnnotation("ProductVersion", "5.0.10");
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
|
||||
{
|
||||
@@ -1055,6 +1055,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<int>("ChannelId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeSpan?>("DailyRebuildTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ProgramScheduleId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudCard>
|
||||
@@ -12,11 +15,49 @@
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
<MudText Class="mt-6">Full changelog is available on <MudLink Href="https://github.com/jasongdove/ErsatzTV/blob/main/CHANGELOG.md">GitHub</MudLink></MudText>
|
||||
<MudTable Class="mt-6"
|
||||
Hover="true"
|
||||
Dense="true"
|
||||
ServerData="@(new Func<TableState, Task<TableData<HealthCheckResult>>>(ServerReload))"
|
||||
@ref="_table">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Health Checks</MudText>
|
||||
</ToolBarContent>
|
||||
<HeaderContent>
|
||||
<MudTh>Check</MudTh>
|
||||
<MudTh>Message</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Check">
|
||||
<div style="display: flex; flex-direction: row; align-items: center">
|
||||
@if (context.Status == HealthCheckStatus.Fail)
|
||||
{
|
||||
<MudIcon Color="@Color.Error" Icon="@Icons.Material.Filled.Error"/>
|
||||
}
|
||||
else if (context.Status == HealthCheckStatus.Warning)
|
||||
{
|
||||
<MudIcon Color="@Color.Warning" Icon="@Icons.Material.Filled.Warning"/>
|
||||
}
|
||||
else if (context.Status == HealthCheckStatus.Info)
|
||||
{
|
||||
<MudIcon Color="@Color.Info" Icon="@Icons.Material.Filled.Info"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Color="@Color.Success" Icon="@Icons.Material.Filled.Check"/>
|
||||
}
|
||||
<div class="ml-2">@context.Title</div>
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Message">@context.Message</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
private string _releaseNotes;
|
||||
private MudTable<HealthCheckResult> _table;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -66,5 +107,16 @@
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<HealthCheckResult>> ServerReload(TableState state)
|
||||
{
|
||||
List<HealthCheckResult> healthCheckResults = await _mediator.Send(new GetAllHealthCheckResults());
|
||||
|
||||
return new TableData<HealthCheckResult>
|
||||
{
|
||||
TotalItems = healthCheckResults.Count,
|
||||
Items = healthCheckResults
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
<ColGroup>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
<col style="width: 180px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>
|
||||
@@ -47,6 +47,11 @@
|
||||
OnClick="@(_ => RebuildPlayout(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Schedule Rebuild">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Update"
|
||||
OnClick="@(_ => ScheduleRebuild(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeletePlayout(context))">
|
||||
@@ -140,6 +145,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScheduleRebuild(PlayoutNameViewModel playout)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "PlayoutId", playout.PlayoutId },
|
||||
{ "ChannelName", playout.ChannelName },
|
||||
{ "ScheduleName", playout.ScheduleName },
|
||||
{ "DailyRebuildTime", playout.DailyRebuildTime }
|
||||
};
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = _dialog.Show<SchedulePlayoutRebuild>("Schedule Playout Rebuild", parameters, options);
|
||||
await dialog.Result;
|
||||
}
|
||||
|
||||
private async Task<TableData<PlayoutNameViewModel>> ServerReload(TableState state)
|
||||
{
|
||||
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsPageSize, state.PageSize.ToString()));
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
@using ErsatzTV.Application.Configuration.Commands
|
||||
@using ErsatzTV.Application.Watermarks
|
||||
@using ErsatzTV.Application.Watermarks.Queries
|
||||
@using ErsatzTV.Core.FFmpeg
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@inject IMediator _mediator
|
||||
@inject ISnackbar _snackbar
|
||||
@@ -57,6 +58,12 @@
|
||||
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect Class="mt-3" Label="VAAPI Driver" @bind-Value="_ffmpegSettings.VaapiDriver" For="@(() => _ffmpegSettings.VaapiDriver)">
|
||||
@foreach (VaapiDriver driver in Enum.GetValues<VaapiDriver>())
|
||||
{
|
||||
<MudSelectItem Value="@driver">@driver</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudForm>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
|
||||
@@ -44,14 +44,35 @@ namespace ErsatzTV.Services
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime firstRun = DateTime.Now;
|
||||
|
||||
// run once immediately at startup
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await DoWork(cancellationToken);
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
int currentMinutes = DateTime.Now.TimeOfDay.Minutes;
|
||||
int toWait = currentMinutes < 30 ? 30 - currentMinutes : 60 - currentMinutes;
|
||||
_logger.LogDebug("Scheduler sleeping for {Minutes} minutes", toWait);
|
||||
await Task.Delay(TimeSpan.FromMinutes(toWait), cancellationToken);
|
||||
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await DoWork(cancellationToken);
|
||||
var roundedMinute = (int)(Math.Round(DateTime.Now.Minute / 5.0) * 5);
|
||||
if (roundedMinute % 30 == 0)
|
||||
{
|
||||
// check for playouts to rebuild every 30 minutes
|
||||
await RebuildPlayouts(cancellationToken);
|
||||
}
|
||||
if (roundedMinute % 60 == 0 && DateTime.Now.Subtract(firstRun) > TimeSpan.FromHours(1))
|
||||
{
|
||||
// do other work every hour (on the hour)
|
||||
await DoWork(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromHours(1), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +92,33 @@ namespace ErsatzTV.Services
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RebuildPlayouts(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Filter(p => p.DailyRebuildTime != null)
|
||||
.Include(p => p.Channel)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (Playout playout in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)))
|
||||
{
|
||||
if (DateTime.Now.Subtract(DateTime.Today.Add(playout.DailyRebuildTime ?? TimeSpan.FromDays(7))) <
|
||||
TimeSpan.FromMinutes(5))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new BuildPlayout(playout.Id, true), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error during scheduler run");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BuildPlayouts(CancellationToken cancellationToken)
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
@inject IMediator _mediator
|
||||
@inject ISnackbar _snackbar
|
||||
@inject ILogger<AddToCollectionDialog> _logger
|
||||
@inject ILogger<CopyFFmpegProfileDialog> _logger
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@using ErsatzTV.Application.Watermarks.Commands
|
||||
@inject IMediator _mediator
|
||||
@inject ISnackbar _snackbar
|
||||
@inject ILogger<AddToCollectionDialog> _logger
|
||||
@inject ILogger<CopyWatermarkDialog> _logger
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Playouts
|
||||
@using ErsatzTV.Application.Playouts.Commands
|
||||
@inject IMediator _mediator
|
||||
@inject ISnackbar _snackbar
|
||||
@inject ILogger<SchedulePlayoutRebuild> _logger
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<EditForm Model="@_dummyModel" OnSubmit="@(_ => Submit())">
|
||||
<MudContainer Class="mb-6">
|
||||
<MudText>
|
||||
@FormatText()
|
||||
</MudText>
|
||||
</MudContainer>
|
||||
<MudSelect Class="mb-6 mx-4" Label="Daily Rebuild Time" @bind-Value="_rebuildTime">
|
||||
<MudSelectItem Value="@(Option<TimeSpan>.None)">Do not automatically rebuild</MudSelectItem>
|
||||
@for (var i = 1; i < 48; i++)
|
||||
{
|
||||
var time = TimeSpan.FromHours(i * 0.5);
|
||||
string formatted = DateTime.Today.Add(time).ToShortTimeString();
|
||||
<MudSelectItem Value="@(Option<TimeSpan>.Some(time))">@formatted</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</EditForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">
|
||||
Save Changes
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
|
||||
[CascadingParameter]
|
||||
MudDialogInstance MudDialog { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int PlayoutId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string ChannelName { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string ScheduleName { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Option<TimeSpan> DailyRebuildTime { get; set; }
|
||||
|
||||
private string FormatText() => $"Enter the time that the playout on channel {ChannelName} with schedule {ScheduleName} should rebuild every day";
|
||||
|
||||
private record DummyModel;
|
||||
|
||||
private readonly DummyModel _dummyModel = new();
|
||||
|
||||
private Option<TimeSpan> _rebuildTime;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_rebuildTime = DailyRebuildTime;
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
Either<BaseError, PlayoutNameViewModel> maybeResult =
|
||||
await _mediator.Send(new UpdatePlayout(PlayoutId, _rebuildTime));
|
||||
|
||||
maybeResult.Match(
|
||||
playout => { MudDialog.Close(DialogResult.Ok(playout)); },
|
||||
error =>
|
||||
{
|
||||
_snackbar.Add(error.Value, Severity.Error);
|
||||
_logger.LogError("Error updating Playout: {Error}", error.Value);
|
||||
MudDialog.Close(DialogResult.Cancel());
|
||||
});
|
||||
}
|
||||
|
||||
private void Cancel(MouseEventArgs e) => MudDialog.Cancel();
|
||||
}
|
||||
@@ -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,15 @@ namespace ErsatzTV
|
||||
AddChannel<IPlexBackgroundServiceRequest>(services);
|
||||
AddChannel<IJellyfinBackgroundServiceRequest>(services);
|
||||
AddChannel<IEmbyBackgroundServiceRequest>(services);
|
||||
|
||||
services.AddScoped<IFFmpegVersionHealthCheck, FFmpegVersionHealthCheck>();
|
||||
services.AddScoped<IFFmpegReportsHealthCheck, FFmpegReportsHealthCheck>();
|
||||
services.AddScoped<IHardwareAccelerationHealthCheck, HardwareAccelerationHealthCheck>();
|
||||
services.AddScoped<IMovieMetadataHealthCheck, MovieMetadataHealthCheck>();
|
||||
services.AddScoped<IEpisodeMetadataHealthCheck, EpisodeMetadataHealthCheck>();
|
||||
services.AddScoped<IZeroDurationHealthCheck, ZeroDurationHealthCheck>();
|
||||
services.AddScoped<IVaapiDriverHealthCheck, VaapiDriverHealthCheck>();
|
||||
services.AddScoped<IHealthCheckService, HealthCheckService>();
|
||||
|
||||
services.AddScoped<IChannelRepository, ChannelRepository>();
|
||||
services.AddScoped<IFFmpegProfileRepository, FFmpegProfileRepository>();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
FROM jrottenberg/ffmpeg:4.3-vaapi2004 AS runtime-base
|
||||
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
|
||||
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata
|
||||
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata intel-media-va-driver-non-free libva-x11-2
|
||||
|
||||
# https://hub.docker.com/_/microsoft-dotnet
|
||||
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
|
||||
|
||||
Reference in New Issue
Block a user