diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs index 8d4bdea6e..419ebf5fb 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core; +using ErsatzTV.Core.Domain; using LanguageExt; using MediatR; @@ -8,6 +9,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands string Name, int ThreadCount, bool Transcode, + HardwareAccelerationKind HardwareAcceleration, int ResolutionId, bool NormalizeResolution, string VideoCodec, diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs index aecb82b4e..b464242da 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs @@ -41,6 +41,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands Name = name, ThreadCount = threadCount, Transcode = request.Transcode, + HardwareAcceleration = request.HardwareAcceleration, ResolutionId = resolutionId, NormalizeResolution = request.NormalizeResolution, VideoCodec = request.VideoCodec, diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs index b65e56632..29cf7c8a0 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core; +using ErsatzTV.Core.Domain; using LanguageExt; using MediatR; @@ -9,6 +10,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands string Name, int ThreadCount, bool Transcode, + HardwareAccelerationKind HardwareAcceleration, int ResolutionId, bool NormalizeResolution, string VideoCodec, diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs index 1bae3326d..8600c4da8 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs @@ -35,6 +35,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands p.Name = update.Name; p.ThreadCount = update.ThreadCount; p.Transcode = update.Transcode; + p.HardwareAcceleration = update.HardwareAcceleration; p.ResolutionId = update.ResolutionId; p.NormalizeResolution = update.NormalizeResolution; p.VideoCodec = update.VideoCodec; diff --git a/ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs b/ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs index db0f993b5..8f16cb872 100644 --- a/ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs +++ b/ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs @@ -1,4 +1,5 @@ using ErsatzTV.Application.Resolutions; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.FFmpegProfiles { @@ -7,6 +8,7 @@ namespace ErsatzTV.Application.FFmpegProfiles string Name, int ThreadCount, bool Transcode, + HardwareAccelerationKind HardwareAcceleration, ResolutionViewModel Resolution, bool NormalizeResolution, string VideoCodec, diff --git a/ErsatzTV.Application/FFmpegProfiles/Mapper.cs b/ErsatzTV.Application/FFmpegProfiles/Mapper.cs index 167285325..e654926bf 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Mapper.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Mapper.cs @@ -11,6 +11,7 @@ namespace ErsatzTV.Application.FFmpegProfiles profile.Name, profile.ThreadCount, profile.Transcode, + profile.HardwareAcceleration, Project(profile.Resolution), profile.NormalizeResolution, profile.VideoCodec, diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs new file mode 100644 index 000000000..48385fc9d --- /dev/null +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs @@ -0,0 +1,344 @@ +using System; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; +using FluentAssertions; +using LanguageExt; +using NUnit.Framework; + +namespace ErsatzTV.Core.Tests.FFmpeg +{ + [TestFixture] + public class FFmpegComplexFilterBuilderTests + { + [TestFixture] + public class Build + { + [Test] + public void Should_Return_None_With_No_Filters() + { + var builder = new FFmpegComplexFilterBuilder(); + + Option result = builder.Build(); + + result.IsNone.Should().BeTrue(); + } + + [Test] + public void Should_Return_Audio_Filter_With_AudioDuration() + { + var duration = TimeSpan.FromMinutes(54); + FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() + .WithAlignedAudio(duration); + + Option result = builder.Build(); + + result.IsSome.Should().BeTrue(); + result.IfSome( + filter => + { + filter.ComplexFilter.Should().Be($"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a]"); + filter.AudioLabel.Should().Be("[a]"); + filter.VideoLabel.Should().Be("0:v"); + }); + } + + [Test] + public void Should_Return_Audio_And_Video_Filter() + { + var duration = TimeSpan.FromMinutes(54); + FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() + .WithAlignedAudio(duration) + .WithDeinterlace(true); + + Option result = builder.Build(); + + result.IsSome.Should().BeTrue(); + result.IfSome( + filter => + { + filter.ComplexFilter.Should().Be( + $"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:v]yadif=1[v]"); + filter.AudioLabel.Should().Be("[a]"); + filter.VideoLabel.Should().Be("[v]"); + }); + } + + [Test] + [TestCase(true, false, false, "[0:v]yadif=1[v]", "[v]")] + [TestCase(true, true, false, "[0:v]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")] + [TestCase(true, false, true, "[0:v]yadif=1,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")] + [TestCase( + true, + true, + true, + "[0:v]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", + "[v]")] + [TestCase(false, true, false, "[0:v]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")] + [TestCase(false, false, true, "[0:v]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")] + [TestCase( + false, + true, + true, + "[0:v]scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", + "[v]")] + public void Should_Return_Software_Video_Filter( + bool deinterlace, + bool scale, + bool pad, + string expectedVideoFilter, + string expectedVideoLabel) + { + FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() + .WithDeinterlace(deinterlace); + + if (scale) + { + builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 }); + } + + if (pad) + { + builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); + } + + Option result = builder.Build(); + + result.IsSome.Should().BeTrue(); + result.IfSome( + filter => + { + filter.ComplexFilter.Should().Be(expectedVideoFilter); + filter.AudioLabel.Should().Be("0:a"); + filter.VideoLabel.Should().Be(expectedVideoLabel); + }); + } + + [Test] + [TestCase(true, false, false, "[0:v]deinterlace_qsv[v]", "[v]")] + [TestCase( + true, + true, + false, + "[0:v]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]", + "[v]")] + [TestCase( + true, + false, + true, + "[0:v]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", + "[v]")] + [TestCase( + true, + true, + true, + "[0:v]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", + "[v]")] + [TestCase( + false, + true, + false, + "[0:v]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]", + "[v]")] + [TestCase( + false, + false, + true, + "[0:v]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", + "[v]")] + [TestCase( + false, + true, + true, + "[0:v]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", + "[v]")] + public void Should_Return_QSV_Video_Filter( + bool deinterlace, + bool scale, + bool pad, + string expectedVideoFilter, + string expectedVideoLabel) + { + FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() + .WithHardwareAcceleration(HardwareAccelerationKind.Qsv) + .WithDeinterlace(deinterlace); + + if (scale) + { + builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 }); + } + + if (pad) + { + builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); + } + + Option result = builder.Build(); + + result.IsSome.Should().BeTrue(); + result.IfSome( + filter => + { + filter.ComplexFilter.Should().Be(expectedVideoFilter); + filter.AudioLabel.Should().Be("0:a"); + filter.VideoLabel.Should().Be(expectedVideoLabel); + }); + } + + [Test] + // TODO: get yadif_cuda working in docker + // [TestCase(true, false, false, "[0:v]yadif_cuda[v]", "[v]")] + // [TestCase( + // true, + // true, + // false, + // "[0:v]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,hwupload[v]", + // "[v]")] + // [TestCase( + // true, + // false, + // true, + // "[0:v]yadif_cuda,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + // "[v]")] + // [TestCase( + // true, + // true, + // true, + // "[0:v]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + // "[v]")] + [TestCase( + true, + true, + false, + "[0:v]scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,hwupload[v]", + "[v]")] + [TestCase( + true, + false, + true, + "[0:v]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + "[v]")] + [TestCase( + true, + true, + true, + "[0:v]scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + "[v]")] + [TestCase( + false, + true, + false, + "[0:v]scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,hwupload[v]", + "[v]")] + [TestCase( + false, + false, + true, + "[0:v]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + "[v]")] + [TestCase( + false, + true, + true, + "[0:v]scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + "[v]")] + public void Should_Return_NVENC_Video_Filter( + bool deinterlace, + bool scale, + bool pad, + string expectedVideoFilter, + string expectedVideoLabel) + { + FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() + .WithHardwareAcceleration(HardwareAccelerationKind.Nvenc) + .WithDeinterlace(deinterlace); + + if (scale) + { + builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 }); + } + + if (pad) + { + builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); + } + + Option result = builder.Build(); + + result.IsSome.Should().BeTrue(); + result.IfSome( + filter => + { + filter.ComplexFilter.Should().Be(expectedVideoFilter); + filter.AudioLabel.Should().Be("0:a"); + filter.VideoLabel.Should().Be(expectedVideoLabel); + }); + } + + [Test] + [TestCase(true, false, false, "[0:v]deinterlace_vaapi[v]", "[v]")] + [TestCase( + true, + true, + false, + "[0:v]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,setsar=1,hwupload[v]", + "[v]")] + [TestCase( + true, + false, + true, + "[0:v]deinterlace_vaapi,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + "[v]")] + [TestCase( + true, + true, + true, + "[0:v]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + "[v]")] + [TestCase(false, true, false, "[0:v]scale_vaapi=w=1920:h=1000,hwdownload,setsar=1,hwupload[v]", "[v]")] + [TestCase( + false, + false, + true, + "[0:v]hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + "[v]")] + [TestCase( + false, + true, + true, + "[0:v]scale_vaapi=w=1920:h=1000,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", + "[v]")] + public void Should_Return_VAAPI_Video_Filter( + bool deinterlace, + bool scale, + bool pad, + string expectedVideoFilter, + string expectedVideoLabel) + { + FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() + .WithHardwareAcceleration(HardwareAccelerationKind.Vaapi) + .WithDeinterlace(deinterlace); + + if (scale) + { + builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 }); + } + + if (pad) + { + builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); + } + + Option result = builder.Build(); + + result.IsSome.Should().BeTrue(); + result.IfSome( + filter => + { + filter.ComplexFilter.Should().Be(expectedVideoFilter); + filter.AudioLabel.Should().Be("0:a"); + filter.VideoLabel.Should().Be(expectedVideoLabel); + }); + } + } + } +} diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs index 5237e37da..5f6ed673b 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs @@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg [TestFixture] public class FFmpegPlaybackSettingsCalculatorTests { + [TestFixture] public class CalculateSettings { private readonly FFmpegPlaybackSettingsCalculator _calculator; @@ -273,6 +274,29 @@ namespace ErsatzTV.Core.Tests.FFmpeg actual.PadToDesiredResolution.Should().BeFalse(); } + [Test] + public void Should_NotPadToDesiredResolution_When_NotNormalizingResolution() + { + FFmpegProfile ffmpegProfile = TestProfile() with + { + NormalizeResolution = false, + Resolution = new Resolution { Width = 1920, Height = 1080 } + }; + + // not anamorphic + var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" }; + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + version, + DateTimeOffset.Now, + DateTimeOffset.Now); + + actual.ScaledSize.IsNone.Should().BeTrue(); + actual.PadToDesiredResolution.Should().BeFalse(); + } + [Test] public void Should_SetDesiredVideoCodec_When_ContentIsPadded_ForTransportStream() { @@ -732,9 +756,33 @@ namespace ErsatzTV.Core.Tests.FFmpeg actual.AudioSampleRate.IfNone(0).Should().Be(48); } + } + + [TestFixture] + public class CalculateSettingsQsv + { + private readonly FFmpegPlaybackSettingsCalculator _calculator; + + public CalculateSettingsQsv() => _calculator = new FFmpegPlaybackSettingsCalculator(); + + [Test] + public void Should_UseHardwareAcceleration() + { + FFmpegProfile ffmpegProfile = + TestProfile() with { HardwareAcceleration = HardwareAccelerationKind.Qsv }; - private FFmpegProfile TestProfile() => - new() { Resolution = new Resolution { Width = 1920, Height = 1080 } }; + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.TransportStream, + ffmpegProfile, + new MediaVersion(), + DateTimeOffset.Now, + DateTimeOffset.Now); + + actual.HardwareAcceleration.Should().Be(HardwareAccelerationKind.Qsv); + } } + + private static FFmpegProfile TestProfile() => + new() { Resolution = new Resolution { Width = 1920, Height = 1080 } }; } } diff --git a/ErsatzTV.Core/Domain/FFmpegProfile.cs b/ErsatzTV.Core/Domain/FFmpegProfile.cs index e1931513f..7a453840b 100644 --- a/ErsatzTV.Core/Domain/FFmpegProfile.cs +++ b/ErsatzTV.Core/Domain/FFmpegProfile.cs @@ -6,6 +6,7 @@ public string Name { get; set; } public int ThreadCount { get; set; } public bool Transcode { get; set; } + public HardwareAccelerationKind HardwareAcceleration { get; set; } public int ResolutionId { get; set; } public Resolution Resolution { get; set; } public bool NormalizeResolution { get; set; } diff --git a/ErsatzTV.Core/Domain/HardwareAccelerationKind.cs b/ErsatzTV.Core/Domain/HardwareAccelerationKind.cs new file mode 100644 index 000000000..0bd1fe3d6 --- /dev/null +++ b/ErsatzTV.Core/Domain/HardwareAccelerationKind.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Domain +{ + public enum HardwareAccelerationKind + { + None = 0, + Qsv = 1, + Nvenc = 2, + Vaapi = 3 + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegComplexFilter.cs b/ErsatzTV.Core/FFmpeg/FFmpegComplexFilter.cs new file mode 100644 index 000000000..45b60f887 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/FFmpegComplexFilter.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.FFmpeg +{ + public record FFmpegComplexFilter(string ComplexFilter, string VideoLabel, string AudioLabel); +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs b/ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs new file mode 100644 index 000000000..19e2d0187 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.FFmpeg; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.FFmpeg +{ + public class FFmpegComplexFilterBuilder + { + private Option _audioDuration = None; + private bool _deinterlace; + private Option _hardwareAccelerationKind = None; + private Option _padToSize = None; + private Option _scaleToSize = None; + + public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind) + { + _hardwareAccelerationKind = Some(hardwareAccelerationKind); + return this; + } + + public FFmpegComplexFilterBuilder WithScaling(IDisplaySize scaleToSize) + { + _scaleToSize = Some(scaleToSize); + return this; + } + + public FFmpegComplexFilterBuilder WithBlackBars(IDisplaySize padToSize) + { + _padToSize = Some(padToSize); + return this; + } + + public FFmpegComplexFilterBuilder WithDeinterlace(bool deinterlace) + { + _deinterlace = deinterlace; + return this; + } + + public FFmpegComplexFilterBuilder WithAlignedAudio(Option audioDuration) + { + _audioDuration = audioDuration; + return this; + } + + public Option Build() + { + var complexFilter = new StringBuilder(); + + var videoLabel = "0:v"; + var audioLabel = "0:a"; + + HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None); + + _audioDuration.IfSome( + audioDuration => + { + complexFilter.Append($"[{audioLabel}]"); + complexFilter.Append($"apad=whole_dur={audioDuration.TotalMilliseconds}ms"); + audioLabel = "[a]"; + complexFilter.Append(audioLabel); + }); + + var filterQueue = new List(); + + if (_deinterlace) + { + string filter = acceleration switch + { + HardwareAccelerationKind.Qsv => "deinterlace_qsv", + HardwareAccelerationKind.Nvenc => "", // TODO: yadif_cuda support in docker + HardwareAccelerationKind.Vaapi => "deinterlace_vaapi", + _ => "yadif=1" + }; + + if (!string.IsNullOrWhiteSpace(filter)) + { + filterQueue.Add(filter); + } + } + + _scaleToSize.IfSome( + size => + { + string filter = acceleration switch + { + HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}", + HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}:format=yuv420p", + HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}", + _ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear" + }; + + if (!string.IsNullOrWhiteSpace(filter)) + { + filterQueue.Add(filter); + } + }); + + if (_scaleToSize.IsSome || _padToSize.IsSome) + { + if (acceleration != HardwareAccelerationKind.None) + { + filterQueue.Add("hwdownload"); + if (_scaleToSize.IsNone && acceleration == HardwareAccelerationKind.Nvenc || + acceleration == HardwareAccelerationKind.Qsv) + { + filterQueue.Add("format=nv12"); + } + } + + filterQueue.Add("setsar=1"); + } + + _padToSize.IfSome(size => filterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2")); + + if ((_scaleToSize.IsSome || _padToSize.IsSome) && acceleration != HardwareAccelerationKind.None) + { + filterQueue.Add( + acceleration == HardwareAccelerationKind.Qsv ? "hwupload=extra_hw_frames=64" : "hwupload"); + } + + if (filterQueue.Any()) + { + // TODO: any audio filter + if (_audioDuration.IsSome) + { + complexFilter.Append(";"); + } + + complexFilter.Append($"[{videoLabel}]"); + complexFilter.Append(string.Join(",", filterQueue)); + videoLabel = "[v]"; + complexFilter.Append(videoLabel); + } + + var filterResult = complexFilter.ToString(); + return string.IsNullOrWhiteSpace(filterResult) + ? Option.None + : new FFmpegComplexFilter(filterResult, videoLabel, audioLabel); + } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs index fb2c434a4..6351e69e5 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.FFmpeg; using LanguageExt; @@ -9,6 +10,8 @@ namespace ErsatzTV.Core.FFmpeg { public int ThreadCount { get; set; } public List FormatFlags { get; set; } + public HardwareAccelerationKind HardwareAcceleration { get; set; } + public string VideoDecoder { get; set; } public bool RealtimeOutput => true; public Option StreamSeek { get; set; } public Option ScaledSize { get; set; } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index d9e057788..fb7c24357 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -67,6 +67,8 @@ namespace ErsatzTV.Core.FFmpeg result.Deinterlace = false; break; case StreamingMode.TransportStream: + result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration; + if (NeedToScale(ffmpegProfile, version)) { IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, version); @@ -77,7 +79,7 @@ namespace ErsatzTV.Core.FFmpeg } IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(version); - if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution)) + if (ffmpegProfile.NormalizeResolution && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution)) { result.PadToDesiredResolution = true; } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs index 3cb6dab5d..bdc53f3e0 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs @@ -21,7 +21,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Text; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.FFmpeg; @@ -31,10 +30,16 @@ namespace ErsatzTV.Core.FFmpeg { internal class FFmpegProcessBuilder { + private static readonly Dictionary QsvMap = new() + { + { "h264", "h264_qsv" }, + { "hevc", "hevc_qsv" }, + { "mpeg2video", "mpeg2_qsv" } + }; + private readonly List _arguments = new(); - private readonly Queue _audioFilters = new(); private readonly string _ffmpegPath; - private readonly Queue _videoFilters = new(); + private FFmpegComplexFilterBuilder _complexFilterBuilder = new(); public FFmpegProcessBuilder(string ffmpegPath) => _ffmpegPath = ffmpegPath; @@ -45,6 +50,37 @@ namespace ErsatzTV.Core.FFmpeg return this; } + public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel) + { + switch (hwAccel) + { + case HardwareAccelerationKind.Qsv: + _arguments.Add("-hwaccel"); + _arguments.Add("qsv"); + _arguments.Add("-init_hw_device"); + _arguments.Add("qsv=qsv:MFX_IMPL_hw_any"); + break; + case HardwareAccelerationKind.Nvenc: + _arguments.Add("-hwaccel"); + _arguments.Add("cuda"); + _arguments.Add("-hwaccel_output_format"); + _arguments.Add("cuda"); + break; + case HardwareAccelerationKind.Vaapi: + _arguments.Add("-hwaccel"); + _arguments.Add("vaapi"); + _arguments.Add("-vaapi_device"); + _arguments.Add("/dev/dri/renderD128"); + _arguments.Add("-hwaccel_output_format"); + _arguments.Add("vaapi"); + break; + } + + _complexFilterBuilder = _complexFilterBuilder.WithHardwareAcceleration(hwAccel); + + return this; + } + public FFmpegProcessBuilder WithRealtimeOutput(bool realtimeOutput) { if (realtimeOutput) @@ -109,6 +145,19 @@ namespace ErsatzTV.Core.FFmpeg return this; } + public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec) + { + if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec)) + { + _arguments.Add("-c:v"); + _arguments.Add(qsvCodec); + } + + _arguments.Add("-i"); + _arguments.Add($"{input}"); + return this; + } + public FFmpegProcessBuilder WithFiltergraph(string graph) { _arguments.Add("-vf"); @@ -242,73 +291,44 @@ namespace ErsatzTV.Core.FFmpeg return this; } - public FFmpegProcessBuilder WithScaling(IDisplaySize displaySize, string algorithm) + public FFmpegProcessBuilder WithScaling(IDisplaySize displaySize) { - _videoFilters.Enqueue($"scale={displaySize.Width}:{displaySize.Height}:flags={algorithm}"); + _complexFilterBuilder = _complexFilterBuilder.WithScaling(displaySize); return this; } public FFmpegProcessBuilder WithBlackBars(IDisplaySize displaySize) { - _videoFilters.Enqueue($"pad={displaySize.Width}:{displaySize.Height}:(ow-iw)/2:(oh-ih)/2"); + _complexFilterBuilder = _complexFilterBuilder.WithBlackBars(displaySize); return this; } public FFmpegProcessBuilder WithAlignedAudio(Option audioDuration) { - audioDuration.IfSome(duration => _audioFilters.Enqueue($"apad=whole_dur={duration.TotalMilliseconds}ms")); + _complexFilterBuilder = _complexFilterBuilder.WithAlignedAudio(audioDuration); return this; } - public FFmpegProcessBuilder WithDeinterlace(bool deinterlace, string algorithm = "yadif=1") + public FFmpegProcessBuilder WithDeinterlace(bool deinterlace) { - if (deinterlace) - { - _videoFilters.Enqueue(algorithm); - } - - return this; - } - - public FFmpegProcessBuilder WithSAR() - { - // TODO: minsiz? - _videoFilters.Enqueue("setsar=1"); + _complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace); return this; } public FFmpegProcessBuilder WithFilterComplex() { - var complexFilter = new StringBuilder(); var videoLabel = "0:v"; var audioLabel = "0:a"; - bool hasVideoFilters = _videoFilters.Any(); - if (hasVideoFilters) - { - (string filter, string finalLabel) = GenerateVideoFilter(_videoFilters); - complexFilter.Append(filter); - videoLabel = finalLabel; - } - if (_audioFilters.Any()) - { - if (hasVideoFilters) + Option maybeFilter = _complexFilterBuilder.Build(); + maybeFilter.IfSome( + filter => { - complexFilter.Append(';'); - } - - (string filter, string finalLabel) = GenerateAudioFilter(_audioFilters); - complexFilter.Append(filter); - audioLabel = finalLabel; - } - - var complex = complexFilter.ToString(); - - if (!string.IsNullOrWhiteSpace(complex)) - { - _arguments.Add("-filter_complex"); - _arguments.Add(complex); - } + _arguments.Add("-filter_complex"); + _arguments.Add(filter.ComplexFilter); + videoLabel = filter.VideoLabel; + audioLabel = filter.AudioLabel; + }); _arguments.Add("-map"); _arguments.Add(videoLabel); @@ -321,7 +341,7 @@ namespace ErsatzTV.Core.FFmpeg public FFmpegProcessBuilder WithQuiet() { - _arguments.AddRange(new[] { "-hide_banner", "-loglevel", "panic", "-nostats" }); + _arguments.AddRange(new[] { "-hide_banner", "-loglevel", "error", "-nostats" }); return this; } @@ -347,26 +367,5 @@ namespace ErsatzTV.Core.FFmpeg StartInfo = startInfo }; } - - private FilterResult GenerateVideoFilter(Queue filterQueue) => - GenerateFilter(filterQueue, "null", 'v'); - - private FilterResult GenerateAudioFilter(Queue filterQueue) => - GenerateFilter(filterQueue, "anull", 'a'); - - private static FilterResult GenerateFilter(Queue filterQueue, string nullFilter, char av) - { - var filter = new StringBuilder(); - var index = 0; - filter.Append($"[0:{av}]{nullFilter}[{av}{index}]"); - while (filterQueue.TryDequeue(out string result)) - { - filter.Append($";[{av}{index}]{result}[{av}{++index}]"); - } - - return new FilterResult(filter.ToString(), $"[{av}{index}]"); - } - - private record FilterResult(string Filter, string FinalLabel); } } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 6bbb0405d..d93b7fae3 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -29,18 +29,18 @@ namespace ErsatzTV.Core.FFmpeg FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath) .WithThreads(playbackSettings.ThreadCount) + .WithHardwareAcceleration(playbackSettings.HardwareAcceleration) .WithQuiet() .WithFormatFlags(playbackSettings.FormatFlags) .WithRealtimeOutput(playbackSettings.RealtimeOutput) .WithSeek(playbackSettings.StreamSeek) - .WithInput(path); + .WithInputCodec(path, playbackSettings.HardwareAcceleration, version.VideoCodec); playbackSettings.ScaledSize.Match( scaledSize => { builder = builder.WithDeinterlace(playbackSettings.Deinterlace) - .WithScaling(scaledSize, playbackSettings.ScalingAlgorithm) - .WithSAR(); + .WithScaling(scaledSize); scaledSize = scaledSize.PadToEven(); if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize)) @@ -57,7 +57,6 @@ namespace ErsatzTV.Core.FFmpeg { builder = builder .WithDeinterlace(playbackSettings.Deinterlace) - .WithSAR() .WithBlackBars(channel.FFmpegProfile.Resolution) .WithAlignedAudio(playbackSettings.AudioDuration) .WithFilterComplex(); diff --git a/ErsatzTV.Infrastructure/Migrations/20210302224925_Add_FFmpegProfileHardwareAcceleration.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210302224925_Add_FFmpegProfileHardwareAcceleration.Designer.cs new file mode 100644 index 000000000..3c5ef0852 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210302224925_Add_FFmpegProfileHardwareAcceleration.Designer.cs @@ -0,0 +1,1494 @@ +// +using System; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ErsatzTV.Infrastructure.Migrations +{ + [DbContext(typeof(TvContext))] + [Migration("20210302224925_Add_FFmpegProfileHardwareAcceleration")] + partial class Add_FFmpegProfileHardwareAcceleration + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtworkKind") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.ToTable("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FFmpegProfileId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("StreamingMode") + .HasColumnType("INTEGER"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FFmpegProfileId"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Channel"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Collection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + { + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "MediaItemId"); + + b.HasIndex("MediaItemId"); + + b.ToTable("CollectionItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("ConfigElement"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.ToTable("EpisodeMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioBitrate") + .HasColumnType("INTEGER"); + + b.Property("AudioBufferSize") + .HasColumnType("INTEGER"); + + b.Property("AudioChannels") + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + b.Property("AudioSampleRate") + .HasColumnType("INTEGER"); + + b.Property("AudioVolume") + .HasColumnType("INTEGER"); + + b.Property("HardwareAcceleration") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeAudio") + .HasColumnType("INTEGER"); + + b.Property("NormalizeAudioCodec") + .HasColumnType("INTEGER"); + + b.Property("NormalizeResolution") + .HasColumnType("INTEGER"); + + b.Property("NormalizeVideoCodec") + .HasColumnType("INTEGER"); + + b.Property("ResolutionId") + .HasColumnType("INTEGER"); + + b.Property("ThreadCount") + .HasColumnType("INTEGER"); + + b.Property("Transcode") + .HasColumnType("INTEGER"); + + b.Property("VideoBitrate") + .HasColumnType("INTEGER"); + + b.Property("VideoBufferSize") + .HasColumnType("INTEGER"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResolutionId"); + + b.ToTable("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScan") + .HasColumnType("TEXT"); + + b.Property("MediaKind") + .HasColumnType("INTEGER"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaSourceId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryPath"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MediaVersionId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaVersionId"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaFile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryPathId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryPathId"); + + b.ToTable("MediaItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("DisplayAspectRatio") + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.Property("VideoScanKind") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.ToTable("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.ToTable("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("ProgramSchedulePlayoutType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("Playout"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Finish") + .HasColumnType("TEXT"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.ToTable("PlayoutItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("PlayoutProgramScheduleAnchor"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexConnection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexPathReplacement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("PlexPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexPathReplacement"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionPlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("ProgramScheduleItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("SeasonMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.ToTable("ShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.ToTable("LocalLibrary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("ShouldSyncItems") + .HasColumnType("INTEGER"); + + b.ToTable("PlexLibrary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaFile", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaFile"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("PlexId") + .HasColumnType("INTEGER"); + + b.ToTable("PlexMediaFile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasIndex("SeasonId"); + + b.ToTable("Episode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("ShowId") + .HasColumnType("INTEGER"); + + b.HasIndex("ShowId"); + + b.ToTable("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Show"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.ToTable("LocalMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("ClientIdentifier") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("ServerName") + .HasColumnType("TEXT"); + + b.ToTable("PlexMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("OfflineTail") + .HasColumnType("INTEGER"); + + b.Property("PlayoutDuration") + .HasColumnType("TEXT"); + + b.ToTable("ProgramScheduleDurationItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleFloodItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleMultipleItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleOneItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexMovie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", null) + .WithMany("Artwork") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Artwork") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Artwork") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Artwork") + .HasForeignKey("SeasonMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Artwork") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") + .WithMany() + .HasForeignKey("FFmpegProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") + .WithMany("CollectionItems") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany("CollectionItems") + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("MediaItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", "Episode") + .WithMany("EpisodeMetadata") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Episode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution") + .WithMany() + .HasForeignKey("ResolutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "MediaSource") + .WithMany("Libraries") + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", "Library") + .WithMany("Paths") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion") + .WithMany("MediaFiles") + .HasForeignKey("MediaVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.LibraryPath", "LibraryPath") + .WithMany("MediaItems") + .HasForeignKey("LibraryPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryPath"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithMany("MediaVersions") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithMany("MediaVersions") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", "Movie") + .WithMany("MovieMetadata") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("Playouts") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Playouts") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 => + { + b1.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("NextScheduleItemId") + .HasColumnType("INTEGER"); + + b1.Property("NextStart") + .HasColumnType("TEXT"); + + b1.HasKey("PlayoutId"); + + b1.HasIndex("NextScheduleItemId"); + + b1.ToTable("Playout"); + + b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem") + .WithMany() + .HasForeignKey("NextScheduleItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("PlayoutId"); + + b1.Navigation("NextScheduleItem"); + }); + + b.Navigation("Anchor"); + + b.Navigation("Channel"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("Items") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("ProgramScheduleAnchors") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany() + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 => + { + b1.Property("PlayoutProgramScheduleAnchorId") + .HasColumnType("INTEGER"); + + b1.Property("Index") + .HasColumnType("INTEGER"); + + b1.Property("Seed") + .HasColumnType("INTEGER"); + + b1.HasKey("PlayoutProgramScheduleAnchorId"); + + b1.ToTable("PlayoutProgramScheduleAnchor"); + + b1.WithOwner() + .HasForeignKey("PlayoutProgramScheduleAnchorId"); + }); + + b.Navigation("Collection"); + + b.Navigation("EnumeratorState"); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource") + .WithMany("Connections") + .HasForeignKey("PlexMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlexMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexPathReplacement", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource") + .WithMany("PathReplacements") + .HasForeignKey("PlexMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlexMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Items") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("MediaItem"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", "Season") + .WithMany("SeasonMetadata") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", "Show") + .WithMany("ShowMetadata") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaFile", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaFile", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaFile", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Episode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Movie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Season", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Show", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMovie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Navigation("Artwork"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => + { + b.Navigation("CollectionItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.Navigation("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.Navigation("Paths"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.Navigation("MediaItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Navigation("CollectionItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Navigation("Libraries"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.Navigation("MediaFiles"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Navigation("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Navigation("Items"); + + b.Navigation("ProgramScheduleAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Navigation("Items"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.Navigation("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Navigation("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.Navigation("EpisodeMetadata"); + + b.Navigation("MediaVersions"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.Navigation("MediaVersions"); + + b.Navigation("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + { + b.Navigation("Episodes"); + + b.Navigation("SeasonMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.Navigation("Seasons"); + + b.Navigation("ShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210302224925_Add_FFmpegProfileHardwareAcceleration.cs b/ErsatzTV.Infrastructure/Migrations/20210302224925_Add_FFmpegProfileHardwareAcceleration.cs new file mode 100644 index 000000000..6e91b2c78 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210302224925_Add_FFmpegProfileHardwareAcceleration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Add_FFmpegProfileHardwareAcceleration : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) => + migrationBuilder.AddColumn( + "HardwareAcceleration", + "FFmpegProfile", + "INTEGER", + nullable: false, + defaultValue: 0); + + protected override void Down(MigrationBuilder migrationBuilder) => + migrationBuilder.DropColumn( + "HardwareAcceleration", + "FFmpegProfile"); + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index a1972ca0f..b11a68a08 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -231,6 +231,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Property("AudioVolume") .HasColumnType("INTEGER"); + b.Property("HardwareAcceleration") + .HasColumnType("INTEGER"); + b.Property("Name") .HasColumnType("TEXT"); diff --git a/ErsatzTV.sln.DotSettings b/ErsatzTV.sln.DotSettings index b3fe1e0f3..94ebaf1d1 100644 --- a/ErsatzTV.sln.DotSettings +++ b/ErsatzTV.sln.DotSettings @@ -39,4 +39,5 @@ True True True + True True \ No newline at end of file diff --git a/ErsatzTV/Pages/FFmpegEditor.razor b/ErsatzTV/Pages/FFmpegEditor.razor index 03dc4c681..825044302 100644 --- a/ErsatzTV/Pages/FFmpegEditor.razor +++ b/ErsatzTV/Pages/FFmpegEditor.razor @@ -49,6 +49,14 @@ + + + @foreach (HardwareAccelerationKind hwAccel in Enum.GetValues()) + { + @hwAccel + } + + Audio diff --git a/ErsatzTV/Validators/FFmpegProfileEditViewModelValidator.cs b/ErsatzTV/Validators/FFmpegProfileEditViewModelValidator.cs index dc6109351..16230f2e8 100644 --- a/ErsatzTV/Validators/FFmpegProfileEditViewModelValidator.cs +++ b/ErsatzTV/Validators/FFmpegProfileEditViewModelValidator.cs @@ -1,10 +1,16 @@ -using ErsatzTV.ViewModels; +using System.Collections.Generic; +using ErsatzTV.Core.Domain; +using ErsatzTV.ViewModels; using FluentValidation; namespace ErsatzTV.Validators { public class FFmpegProfileEditViewModelValidator : AbstractValidator { + private static readonly List QsvEncoders = new() { "h264_qsv", "hevc_qsv", "mpeg2_qsv" }; + private static readonly List NvencEncoders = new() { "h264_nvenc", "hevc_nvenc" }; + private static readonly List VaapiEncoders = new() { "h264_vaapi", "hevc_vaapi", "mpeg2_vaapi" }; + public FFmpegProfileEditViewModelValidator() { RuleFor(x => x.Name).NotEmpty(); @@ -23,6 +29,38 @@ namespace ErsatzTV.Validators RuleFor(x => x.AudioVolume).GreaterThanOrEqualTo(0); RuleFor(x => x.AudioChannels).GreaterThan(0); }); + + When( + x => x.HardwareAcceleration == HardwareAccelerationKind.Qsv, + () => + { + RuleFor(x => x.VideoCodec).Must(c => QsvEncoders.Contains(c)) + .WithMessage("QSV codec is required (h264_qsv, hevc_qsv, mpeg2_qsv)"); + }); + + When( + x => x.HardwareAcceleration == HardwareAccelerationKind.Nvenc, + () => + { + RuleFor(x => x.VideoCodec).Must(c => NvencEncoders.Contains(c)) + .WithMessage("NVENC codec is required (h264_nvenc, hevc_nvenc)"); + }); + + When( + x => x.HardwareAcceleration == HardwareAccelerationKind.Vaapi, + () => + { + RuleFor(x => x.VideoCodec).Must(c => VaapiEncoders.Contains(c)) + .WithMessage("VAAPI codec is required (h264_vaapi, hevc_vaapi, mpeg2_vaapi)"); + }); + + When( + x => x.HardwareAcceleration == HardwareAccelerationKind.None, + () => + { + RuleFor(x => x.VideoCodec).Must(c => !QsvEncoders.Contains(c) && !NvencEncoders.Contains(c)) + .WithMessage("Hardware acceleration is required for this codec"); + }); } } } diff --git a/ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs b/ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs index 7fcaba813..a18e082e9 100644 --- a/ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs +++ b/ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs @@ -1,6 +1,7 @@ using ErsatzTV.Application.FFmpegProfiles; using ErsatzTV.Application.FFmpegProfiles.Commands; using ErsatzTV.Application.Resolutions; +using ErsatzTV.Core.Domain; namespace ErsatzTV.ViewModels { @@ -27,6 +28,7 @@ namespace ErsatzTV.ViewModels Resolution = viewModel.Resolution; ThreadCount = viewModel.ThreadCount; Transcode = viewModel.Transcode; + HardwareAcceleration = viewModel.HardwareAcceleration; VideoBitrate = viewModel.VideoBitrate; VideoBufferSize = viewModel.VideoBufferSize; VideoCodec = viewModel.VideoCodec; @@ -47,6 +49,7 @@ namespace ErsatzTV.ViewModels public ResolutionViewModel Resolution { get; set; } public int ThreadCount { get; set; } public bool Transcode { get; set; } + public HardwareAccelerationKind HardwareAcceleration { get; set; } public int VideoBitrate { get; set; } public int VideoBufferSize { get; set; } public string VideoCodec { get; set; } @@ -56,6 +59,7 @@ namespace ErsatzTV.ViewModels Name, ThreadCount, Transcode, + HardwareAcceleration, Resolution.Id, NormalizeResolution, VideoCodec, @@ -78,6 +82,7 @@ namespace ErsatzTV.ViewModels Name, ThreadCount, Transcode, + HardwareAcceleration, Resolution.Id, NormalizeResolution, VideoCodec, diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..f924b4cf3 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,40 @@ +FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime + +FROM jrottenberg/ffmpeg:4.3-ubuntu2004 AS runtime-base +COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet +RUN apt-get update && apt-get install -y libicu-dev + +# https://hub.docker.com/_/microsoft-dotnet +FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build +RUN apt-get update && apt-get install -y ca-certificates +WORKDIR /source + +# copy csproj and restore as distinct layers +COPY *.sln . +COPY ErsatzTV/*.csproj ./ErsatzTV/ +COPY generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/*.csproj ./generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/ +COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/ +COPY ErsatzTV.CommandLine/*.csproj ./ErsatzTV.CommandLine/ +COPY ErsatzTV.Core/*.csproj ./ErsatzTV.Core/ +COPY ErsatzTV.Core.Tests/*.csproj ./ErsatzTV.Core.Tests/ +COPY ErsatzTV.Infrastructure/*.csproj ./ErsatzTV.Infrastructure/ +RUN dotnet restore -r linux-x64 + +# copy everything else and build app +COPY ErsatzTV/. ./ErsatzTV/ +COPY generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/. ./generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/ +COPY ErsatzTV.Application/. ./ErsatzTV.Application/ +COPY ErsatzTV.CommandLine/. ./ErsatzTV.CommandLine/ +COPY ErsatzTV.Core/. ./ErsatzTV.Core/ +COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/ +COPY ErsatzTV.Infrastructure/. ./ErsatzTV.Infrastructure/ +WORKDIR /source/ErsatzTV +ARG INFO_VERSION="unknown" +RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION} + +# final stage/image +FROM runtime-base +WORKDIR /app +EXPOSE 8409 +COPY --from=build /app ./ +ENTRYPOINT ["./ErsatzTV"] diff --git a/docker/docker-compose.nvidia.yml b/docker/docker-compose.nvidia.yml new file mode 100644 index 000000000..b44716dc7 --- /dev/null +++ b/docker/docker-compose.nvidia.yml @@ -0,0 +1,15 @@ +version: "3.1" + +services: + ersatztv: + build: + context: .. + dockerfile: docker/nvidia/Dockerfile + environment: + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,utility,video + deploy: + resources: + reservations: + devices: + - capabilities: [ gpu ] diff --git a/docker/docker-compose.vaapi.yml b/docker/docker-compose.vaapi.yml new file mode 100644 index 000000000..a00c42a51 --- /dev/null +++ b/docker/docker-compose.vaapi.yml @@ -0,0 +1,8 @@ +version: "3.1" + +services: + ersatztv: + build: + dockerfile: docker/vaapi/Dockerfile + devices: + - /dev/dri/renderD128:/dev/dri/renderD128 diff --git a/docker-compose.yml b/docker/docker-compose.yml similarity index 70% rename from docker-compose.yml rename to docker/docker-compose.yml index 7340deb27..a1d8b5eaf 100644 --- a/docker-compose.yml +++ b/docker/docker-compose.yml @@ -3,13 +3,14 @@ services: ersatztv: build: - context: . + context: .. + dockerfile: docker/Dockerfile args: INFO_VERSION: "docker-compose-develop" ports: - "8409:8409" volumes: - ersatztv:/root/.local/share/ersatztv - #- /media/shared:/media/shared:ro + #- /media/shared:/media/shared:ro volumes: ersatztv: diff --git a/docker/nvidia/Dockerfile b/docker/nvidia/Dockerfile new file mode 100644 index 000000000..935e4d27f --- /dev/null +++ b/docker/nvidia/Dockerfile @@ -0,0 +1,40 @@ +FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime + +FROM jrottenberg/ffmpeg:4.3-nvidia1804 AS runtime-base +COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet +RUN apt-get update && apt-get install -y libicu-dev + +# https://hub.docker.com/_/microsoft-dotnet +FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build +RUN apt-get update && apt-get install -y ca-certificates +WORKDIR /source + +# copy csproj and restore as distinct layers +COPY *.sln . +COPY ErsatzTV/*.csproj ./ErsatzTV/ +COPY generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/*.csproj ./generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/ +COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/ +COPY ErsatzTV.CommandLine/*.csproj ./ErsatzTV.CommandLine/ +COPY ErsatzTV.Core/*.csproj ./ErsatzTV.Core/ +COPY ErsatzTV.Core.Tests/*.csproj ./ErsatzTV.Core.Tests/ +COPY ErsatzTV.Infrastructure/*.csproj ./ErsatzTV.Infrastructure/ +RUN dotnet restore -r linux-x64 + +# copy everything else and build app +COPY ErsatzTV/. ./ErsatzTV/ +COPY generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/. ./generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/ +COPY ErsatzTV.Application/. ./ErsatzTV.Application/ +COPY ErsatzTV.CommandLine/. ./ErsatzTV.CommandLine/ +COPY ErsatzTV.Core/. ./ErsatzTV.Core/ +COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/ +COPY ErsatzTV.Infrastructure/. ./ErsatzTV.Infrastructure/ +WORKDIR /source/ErsatzTV +ARG INFO_VERSION="unknown" +RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION} + +# final stage/image +FROM runtime-base +WORKDIR /app +EXPOSE 8409 +COPY --from=build /app ./ +ENTRYPOINT ["./ErsatzTV"] diff --git a/Dockerfile b/docker/vaapi/Dockerfile similarity index 85% rename from Dockerfile rename to docker/vaapi/Dockerfile index db895e8c0..694ce37a0 100644 --- a/Dockerfile +++ b/docker/vaapi/Dockerfile @@ -1,5 +1,8 @@ -FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS runtime-base -RUN apt-get update && apt-get install -y ffmpeg +FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime + +FROM jrottenberg/ffmpeg:4.3-vaapi1804 AS runtime-base +COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet +RUN apt-get update && apt-get install -y libicu-dev # https://hub.docker.com/_/microsoft-dotnet FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build