Compare commits

...
Author SHA1 Message Date
Jason Dove ca8bcacbd3 update changelog for release 59 [no ci] 2021-09-18 14:35:01 -05:00
Jason Dove f27286d1dd properly disable transcoding when unchecked in mpeg-ts mode (#378)
* properly disable transcoding in MPEG-TS mode

* update changelog
2021-09-18 14:21:49 -05:00
Jason Dove 23870b75f7 update changelog [no ci] 2021-09-18 14:02:49 -05:00
Jason Dove 7f5a91c643 include libva-x11-2 in vaapi docker image 2021-09-18 13:25:01 -05:00
Jason Dove f1f50e883c add vaapi driver setting and health check (#377)
* add vaapi driver option

* add vaapi driver setting and health check
2021-09-18 13:00:36 -05:00
Jason Dove 7506f49f5b remove codeql [no docker] 2021-09-18 11:30:16 -05:00
Jason Dove 944f1e4307 add scheduled playout rebuild (#376)
* configure scheduled playout rebuild

* implement scheduled playout rebuild

* remove variable
2021-09-18 11:23:58 -05:00
Jason Dove f7de9ac5ea include intel-media-va-driver-non-free in vaapi image (#375)
* include intel-media-va-driver-non-free in vaapi image

* tweak changelog
2021-09-17 21:13:27 -05:00
Jason Dove 1eb51ad2f4 add some health checks to home page (#374) 2021-09-17 17:59:59 -05:00
Jason Dove c3e0aaf0b7 missing metadata fixes (#373) 2021-09-17 08:53:41 -05:00
56 changed files with 4361 additions and 114 deletions
-71
View File
@@ -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
View File
@@ -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);
}
}
@@ -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
};
+1
View File
@@ -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");
+1 -1
View File
@@ -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; }
+3 -1
View File
@@ -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
+5 -2
View File
@@ -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)
+12
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Health
{
public enum HealthCheckStatus
{
Pass = 0,
Fail = 1,
Warning = 2,
Info = 3,
NotApplicable = 4
}
}
+9
View File
@@ -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;
}
}
+2 -2
View File
@@ -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());
}
}
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");
+52
View File
@@ -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
};
}
}
+21 -1
View File
@@ -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()));
+7
View File
@@ -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>
+51 -3
View File
@@ -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>
+1 -1
View File
@@ -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();
}
+13
View File
@@ -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>();
+1 -1
View File
@@ -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