add preferred language (#109)

* add explicit warning for zero/invalid duration media items

* set dateadded on plex media versions

* add media stream table

* save local media streams to db

* save plex media streams to db

* add preferred language settings (no validation)

* use preferred language if possible

* code cleanup

* proper language code validation

* force scan of all libraries to pull in media streams
This commit is contained in:
Jason Dove
2021-03-28 21:54:48 +00:00
committed by GitHub
parent 51b671dec7
commit d303bc0158
52 changed files with 6265 additions and 181 deletions
@@ -8,5 +8,6 @@ namespace ErsatzTV.Application.Channels
string Name, string Name,
int FFmpegProfileId, int FFmpegProfileId,
string Logo, string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode); StreamingMode StreamingMode);
} }
@@ -11,5 +11,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Number, string Number,
int FFmpegProfileId, int FFmpegProfileId,
string Logo, string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>; StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
} }
@@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -9,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using static ErsatzTV.Application.Channels.Mapper; using static ErsatzTV.Application.Channels.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Channels.Commands namespace ErsatzTV.Application.Channels.Commands
{ {
@@ -36,9 +39,10 @@ namespace ErsatzTV.Application.Channels.Commands
_channelRepository.Add(c).Map(ProjectToViewModel); _channelRepository.Add(c).Map(ProjectToViewModel);
private async Task<Validation<BaseError, Channel>> Validate(CreateChannel request) => private async Task<Validation<BaseError, Channel>> Validate(CreateChannel request) =>
(ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request)) (ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request),
ValidatePreferredLanguage(request))
.Apply( .Apply(
(name, number, ffmpegProfileId) => (name, number, ffmpegProfileId, preferredLanguageCode) =>
{ {
var artwork = new List<Artwork>(); var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo)) if (!string.IsNullOrWhiteSpace(request.Logo))
@@ -59,7 +63,8 @@ namespace ErsatzTV.Application.Channels.Commands
Number = number, Number = number,
FFmpegProfileId = ffmpegProfileId, FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode, StreamingMode = request.StreamingMode,
Artwork = artwork Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode
}; };
}); });
@@ -67,6 +72,14 @@ namespace ErsatzTV.Application.Channels.Commands
createChannel.NotEmpty(c => c.Name) createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name)); .Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
private Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredLanguageCode)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel) private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel)
{ {
Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number); Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number);
@@ -12,5 +12,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Number, string Number,
int FFmpegProfileId, int FFmpegProfileId,
string Logo, string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>; StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
} }
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
@@ -32,6 +33,7 @@ namespace ErsatzTV.Application.Channels.Commands
c.Name = update.Name; c.Name = update.Name;
c.Number = update.Number; c.Number = update.Number;
c.FFmpegProfileId = update.FFmpegProfileId; c.FFmpegProfileId = update.FFmpegProfileId;
c.PreferredLanguageCode = update.PreferredLanguageCode;
if (!string.IsNullOrWhiteSpace(update.Logo)) if (!string.IsNullOrWhiteSpace(update.Logo))
{ {
@@ -65,8 +67,9 @@ namespace ErsatzTV.Application.Channels.Commands
} }
private async Task<Validation<BaseError, Channel>> Validate(UpdateChannel request) => private async Task<Validation<BaseError, Channel>> Validate(UpdateChannel request) =>
(await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request)) (await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request),
.Apply((channelToUpdate, _, _) => channelToUpdate); ValidatePreferredLanguage(request))
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
private Task<Validation<BaseError, Channel>> ChannelMustExist(UpdateChannel updateChannel) => private Task<Validation<BaseError, Channel>> ChannelMustExist(UpdateChannel updateChannel) =>
_channelRepository.Get(updateChannel.ChannelId) _channelRepository.Get(updateChannel.ChannelId)
@@ -92,5 +95,12 @@ namespace ErsatzTV.Application.Channels.Commands
return BaseError.New("Channel number must be unique"); return BaseError.New("Channel number must be unique");
} }
private Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredLanguageCode)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
} }
} }
+1
View File
@@ -13,6 +13,7 @@ namespace ErsatzTV.Application.Channels
channel.Name, channel.Name,
channel.FFmpegProfileId, channel.FFmpegProfileId,
GetLogo(channel), GetLogo(channel),
channel.PreferredLanguageCode,
channel.StreamingMode); channel.StreamingMode);
private static string GetLogo(Channel channel) => private static string GetLogo(Channel channel) =>
@@ -134,6 +134,23 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder); Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
} }
await _configElementRepository.Get(ConfigElementKey.FFmpegPreferredLanguageCode).Match(
ce =>
{
ce.Value = request.Settings.PreferredLanguageCode;
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{
Key = ConfigElementKey.FFmpegPreferredLanguageCode.Key,
Value = request.Settings.PreferredLanguageCode
};
_configElementRepository.Add(ce);
});
return Unit.Default; return Unit.Default;
} }
} }
@@ -5,6 +5,7 @@
public string FFmpegPath { get; set; } public string FFmpegPath { get; set; }
public string FFprobePath { get; set; } public string FFprobePath { get; set; }
public int DefaultFFmpegProfileId { get; set; } public int DefaultFFmpegProfileId { get; set; }
public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; } public bool SaveReports { get; set; }
} }
} }
@@ -24,13 +24,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId); await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
Option<bool> saveReports = Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports); await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
Option<string> preferredLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
return new FFmpegSettingsViewModel return new FFmpegSettingsViewModel
{ {
FFmpegPath = ffmpegPath.IfNone(string.Empty), FFmpegPath = ffmpegPath.IfNone(string.Empty),
FFprobePath = ffprobePath.IfNone(string.Empty), FFprobePath = ffprobePath.IfNone(string.Empty),
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0), DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0),
SaveReports = saveReports.IfNone(false) SaveReports = saveReports.IfNone(false),
PreferredLanguageCode = preferredLanguageCode.IfNone("eng")
}; };
} }
} }
@@ -63,7 +63,7 @@ namespace ErsatzTV.Application.Streaming.Queries
.Map(result => result.IfNone(false)); .Map(result => result.IfNone(false));
return Right<BaseError, Process>( return Right<BaseError, Process>(
_ffmpegProcessService.ForPlayoutItem( await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath, ffmpegPath,
saveReports, saveReports,
channel, channel,
@@ -18,7 +18,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ {
var builder = new FFmpegComplexFilterBuilder(); var builder = new FFmpegComplexFilterBuilder();
Option<FFmpegComplexFilter> result = builder.Build(); Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsNone.Should().BeTrue(); result.IsNone.Should().BeTrue();
} }
@@ -30,15 +30,15 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration); .WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build(); Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
filter => filter =>
{ {
filter.ComplexFilter.Should().Be($"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a]"); filter.ComplexFilter.Should().Be($"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a]");
filter.AudioLabel.Should().Be("[a]"); filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("0:V"); filter.VideoLabel.Should().Be("0:0");
}); });
} }
@@ -50,36 +50,36 @@ namespace ErsatzTV.Core.Tests.FFmpeg
.WithAlignedAudio(duration) .WithAlignedAudio(duration)
.WithDeinterlace(true); .WithDeinterlace(true);
Option<FFmpegComplexFilter> result = builder.Build(); Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
filter => filter =>
{ {
filter.ComplexFilter.Should().Be( filter.ComplexFilter.Should().Be(
$"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:V]yadif=1[v]"); $"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:0]yadif=1[v]");
filter.AudioLabel.Should().Be("[a]"); filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("[v]"); filter.VideoLabel.Should().Be("[v]");
}); });
} }
[Test] [Test]
[TestCase(true, false, false, "[0:V]yadif=1[v]", "[v]")] [TestCase(true, false, false, "[0:0]yadif=1[v]", "[v]")]
[TestCase(true, true, false, "[0:V]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")] [TestCase(true, true, false, "[0:0]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, false, true, "[0:0]yadif=1,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
[TestCase( [TestCase(
true, true,
true, 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]", "[0:0]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
"[v]")] "[v]")]
[TestCase(false, true, false, "[0:V]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")] [TestCase(false, true, false, "[0:0]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, false, true, "[0:0]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
true, true,
"[0:V]scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[0:0]scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
"[v]")] "[v]")]
public void Should_Return_Software_Video_Filter( public void Should_Return_Software_Video_Filter(
bool deinterlace, bool deinterlace,
@@ -101,55 +101,55 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(); Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
filter => filter =>
{ {
filter.ComplexFilter.Should().Be(expectedVideoFilter); filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a"); filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel); filter.VideoLabel.Should().Be(expectedVideoLabel);
}); });
} }
[Test] [Test]
[TestCase(true, false, false, "[0:V]deinterlace_qsv[v]", "[v]")] [TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
[TestCase( [TestCase(
true, true,
true, true,
false, false,
"[0:V]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]", "[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
false, false,
true, 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]", "[0:0]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
true, 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]", "[0:0]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]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
false, false,
"[0:V]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]", "[0:0]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
false, false,
true, true,
"[0:V]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", "[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
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]", "[0:0]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]")] "[v]")]
public void Should_Return_QSV_Video_Filter( public void Should_Return_QSV_Video_Filter(
bool deinterlace, bool deinterlace,
@@ -172,14 +172,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(); Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
filter => filter =>
{ {
filter.ComplexFilter.Should().Be(expectedVideoFilter); filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a"); filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel); filter.VideoLabel.Should().Be(expectedVideoLabel);
}); });
} }
@@ -209,37 +209,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]", "[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
false, false,
true, true,
"[0:V]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
true, true,
true, true,
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
false, false,
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]", "[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
false, false,
true, true,
"[0:V]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
true, true,
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
public void Should_Return_NVENC_Video_Filter( public void Should_Return_NVENC_Video_Filter(
bool deinterlace, bool deinterlace,
@@ -262,104 +262,104 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(); Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
filter => filter =>
{ {
filter.ComplexFilter.Should().Be(expectedVideoFilter); filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a"); filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel); filter.VideoLabel.Should().Be(expectedVideoLabel);
}); });
} }
[Test] [Test]
[TestCase("h264", true, false, false, "[0:V]deinterlace_vaapi[v]", "[v]")] [TestCase("h264", true, false, false, "[0:0]deinterlace_vaapi[v]", "[v]")]
[TestCase( [TestCase(
"h264", "h264",
true, true,
true, true,
false, false,
"[0:V]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
true, true,
false, false,
true, true,
"[0:V]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
true, true,
true, true,
true, true,
"[0:V]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
true, true,
false, false,
"[0:V]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
false, false,
true, true,
"[0:V]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
true, true,
true, true,
"[0:V]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase("mpeg4", true, false, false, "[0:V]hwupload,deinterlace_vaapi[v]", "[v]")] [TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
true, true,
true, true,
false, false,
"[0:V]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
true, true,
false, false,
true, true,
"[0:V]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
true, true,
true, true,
true, true,
"[0:V]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
false, false,
true, true,
false, false,
"[0:V]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
false, false,
false, false,
true, true,
"[0:V]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
false, false,
true, true,
true, true,
"[0:V]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
public void Should_Return_VAAPI_Video_Filter( public void Should_Return_VAAPI_Video_Filter(
string codec, string codec,
@@ -384,14 +384,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(); Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
filter => filter =>
{ {
filter.ComplexFilter.Should().Be(expectedVideoFilter); filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a"); filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel); filter.VideoLabel.Should().Be(expectedVideoLabel);
}); });
} }
@@ -25,6 +25,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -40,6 +42,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreaming,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -55,6 +59,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -72,6 +78,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreaming,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -89,6 +97,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -104,6 +114,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreaming,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -121,6 +133,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
now, now,
now.AddMinutes(5)); now.AddMinutes(5));
@@ -139,6 +153,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreaming,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
now, now,
now.AddMinutes(5)); now.AddMinutes(5));
@@ -155,6 +171,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -177,6 +195,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -199,6 +219,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -221,6 +243,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -244,6 +268,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -267,6 +293,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreaming,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -290,6 +318,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -315,6 +345,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -337,12 +369,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
// not anamorphic // not anamorphic
var version = new MediaVersion var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" }; { Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -365,12 +399,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
// not anamorphic // not anamorphic
var version = new MediaVersion var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" }; { Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreaming,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -392,12 +428,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
// not anamorphic // not anamorphic
var version = new MediaVersion var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "libx264" }; { Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "libx264" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -420,12 +458,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
// not anamorphic // not anamorphic
var version = new MediaVersion var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" }; { Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -452,6 +492,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -473,12 +515,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
// not anamorphic // not anamorphic
var version = new MediaVersion var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" }; { Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -505,6 +549,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -527,12 +573,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
// not anamorphic // not anamorphic
var version = new MediaVersion var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" }; { Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -550,12 +598,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioCodec = "aac" AudioCodec = "aac"
}; };
var version = new MediaVersion { AudioCodec = "aac" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "aac" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -571,12 +621,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioCodec = "aac" AudioCodec = "aac"
}; };
var version = new MediaVersion { AudioCodec = "ac3" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -592,12 +644,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioCodec = "aac" AudioCodec = "aac"
}; };
var version = new MediaVersion { AudioCodec = "ac3" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -613,12 +667,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioCodec = "aac" AudioCodec = "aac"
}; };
var version = new MediaVersion { AudioCodec = "ac3" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreaming,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -634,12 +690,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioBitrate = 2424 AudioBitrate = 2424
}; };
var version = new MediaVersion { AudioCodec = "ac3" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -655,12 +713,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioBufferSize = 2424 AudioBufferSize = 2424
}; };
var version = new MediaVersion { AudioCodec = "ac3" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -678,12 +738,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioChannels = 6 AudioChannels = 6
}; };
var version = new MediaVersion { AudioCodec = "ac3" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -701,12 +763,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioSampleRate = 48 AudioSampleRate = 48
}; };
var version = new MediaVersion { AudioCodec = "ac3" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -723,12 +787,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioChannels = 6 AudioChannels = 6
}; };
var version = new MediaVersion { AudioCodec = "ac3" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -745,12 +811,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
AudioSampleRate = 48 AudioSampleRate = 48
}; };
var version = new MediaVersion { AudioCodec = "ac3" }; var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -775,6 +843,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream, StreamingMode.TransportStream,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now); DateTimeOffset.Now);
@@ -79,7 +79,7 @@ namespace ErsatzTV.Core.Tests.Plex
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv"); result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
} }
[Test] [Test]
public async Task PlexWindows_To_EtvLinux_UncPath() public async Task PlexWindows_To_EtvLinux_UncPath()
{ {
@@ -111,7 +111,7 @@ namespace ErsatzTV.Core.Tests.Plex
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv"); result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
} }
[Test] [Test]
public async Task PlexWindows_To_EtvLinux_UncPathWithTrailingSlash() public async Task PlexWindows_To_EtvLinux_UncPathWithTrailingSlash()
{ {
+1 -2
View File
@@ -16,8 +16,7 @@ namespace ErsatzTV.Core.Domain
public FFmpegProfile FFmpegProfile { get; set; } public FFmpegProfile FFmpegProfile { get; set; }
public StreamingMode StreamingMode { get; set; } public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; } public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; } public List<Artwork> Artwork { get; set; }
// public SourceMode Mode { get; set; } public string PreferredLanguageCode { get; set; }
} }
} }
+1
View File
@@ -11,6 +11,7 @@
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id"); public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id"); public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports"); public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey SearchIndexVersion => new("search_index.version"); public static ConfigElementKey SearchIndexVersion => new("search_index.version");
} }
} }
@@ -0,0 +1,18 @@
namespace ErsatzTV.Core.Domain
{
public class MediaStream
{
public int Id { get; set; }
public int Index { get; set; }
public string Codec { get; set; }
public string Profile { get; set; }
public MediaStreamKind MediaStreamKind { get; set; }
public string Language { get; set; }
public int Channels { get; set; }
public string Title { get; set; }
public bool Default { get; set; }
public bool Forced { get; set; }
public int MediaVersionId { get; set; }
public MediaVersion MediaVersion { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public enum MediaStreamKind
{
Video = 1,
Audio = 2,
Subtitle = 3
}
}
@@ -8,15 +8,21 @@ namespace ErsatzTV.Core.Domain
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public List<MediaFile> MediaFiles { get; set; } public List<MediaFile> MediaFiles { get; set; }
public List<MediaStream> Streams { get; set; }
public TimeSpan Duration { get; set; } public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; } public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; } public string DisplayAspectRatio { get; set; }
[Obsolete("Use MediaSource instead")]
public string VideoCodec { get; set; } public string VideoCodec { get; set; }
[Obsolete("Use MediaSource instead")]
public string VideoProfile { get; set; } public string VideoProfile { get; set; }
[Obsolete("Use MediaSource instead")]
public string AudioCodec { get; set; } public string AudioCodec { get; set; }
public VideoScanKind VideoScanKind { get; set; } public VideoScanKind VideoScanKind { get; set; }
public DateTime DateAdded { get; set; } public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; } public DateTime DateUpdated { get; set; }
-9
View File
@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public enum SourceMode
{
Transcode,
DirectPlay,
DirectPaths
}
}
@@ -54,12 +54,12 @@ namespace ErsatzTV.Core.FFmpeg
return this; return this;
} }
public Option<FFmpegComplexFilter> Build() public Option<FFmpegComplexFilter> Build(int videoStreamIndex, int audioStreamIndex)
{ {
var complexFilter = new StringBuilder(); var complexFilter = new StringBuilder();
var videoLabel = "0:V"; var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = "0:a"; var audioLabel = $"0:{audioStreamIndex}";
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None); HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch bool isHardwareDecode = acceleration switch
@@ -45,6 +45,8 @@ namespace ErsatzTV.Core.FFmpeg
StreamingMode streamingMode, StreamingMode streamingMode,
FFmpegProfile ffmpegProfile, FFmpegProfile ffmpegProfile,
MediaVersion version, MediaVersion version,
MediaStream videoStream,
MediaStream audioStream,
DateTimeOffset start, DateTimeOffset start,
DateTimeOffset now) DateTimeOffset now)
{ {
@@ -85,7 +87,7 @@ namespace ErsatzTV.Core.FFmpeg
} }
if (result.ScaledSize.IsSome || result.PadToDesiredResolution || if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
NeedToNormalizeVideoCodec(ffmpegProfile, version)) NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
{ {
result.VideoCodec = ffmpegProfile.VideoCodec; result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate; result.VideoBitrate = ffmpegProfile.VideoBitrate;
@@ -96,7 +98,7 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = "copy"; result.VideoCodec = "copy";
} }
if (NeedToNormalizeAudioCodec(ffmpegProfile, version)) if (NeedToNormalizeAudioCodec(ffmpegProfile, audioStream))
{ {
result.AudioCodec = ffmpegProfile.AudioCodec; result.AudioCodec = ffmpegProfile.AudioCodec;
result.AudioBitrate = ffmpegProfile.AudioBitrate; result.AudioBitrate = ffmpegProfile.AudioBitrate;
@@ -104,7 +106,11 @@ namespace ErsatzTV.Core.FFmpeg
if (ffmpegProfile.NormalizeAudio) if (ffmpegProfile.NormalizeAudio)
{ {
result.AudioChannels = ffmpegProfile.AudioChannels; if (audioStream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
result.AudioSampleRate = ffmpegProfile.AudioSampleRate; result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = version.Duration; result.AudioDuration = version.Duration;
} }
@@ -152,11 +158,11 @@ namespace ErsatzTV.Core.FFmpeg
private static bool IsOddSize(MediaVersion version) => private static bool IsOddSize(MediaVersion version) =>
version.Height % 2 == 1 || version.Width % 2 == 1; version.Height % 2 == 1 || version.Width % 2 == 1;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaVersion version) => private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaStream videoStream) =>
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != version.VideoCodec; ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != videoStream.Codec;
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaVersion version) => private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaStream audioStream) =>
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != version.AudioCodec; ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != audioStream.Codec;
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version) private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
{ {
+4 -4
View File
@@ -329,12 +329,12 @@ namespace ErsatzTV.Core.FFmpeg
return this; return this;
} }
public FFmpegProcessBuilder WithFilterComplex() public FFmpegProcessBuilder WithFilterComplex(int videoStreamIndex, int audioStreamIndex)
{ {
var videoLabel = "0:V"; var videoLabel = $"0:v:{videoStreamIndex}";
var audioLabel = "0:a"; var audioLabel = $"0:a:{audioStreamIndex}";
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(); Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, audioStreamIndex);
maybeFilter.IfSome( maybeFilter.IfSome(
filter => filter =>
{ {
+20 -7
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt; using LanguageExt;
@@ -8,12 +9,18 @@ namespace ErsatzTV.Core.FFmpeg
{ {
public class FFmpegProcessService public class FFmpegProcessService
{ {
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator; private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
public FFmpegProcessService(FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService) => public FFmpegProcessService(
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
IFFmpegStreamSelector ffmpegStreamSelector)
{
_playbackSettingsCalculator = ffmpegPlaybackSettingsService; _playbackSettingsCalculator = ffmpegPlaybackSettingsService;
_ffmpegStreamSelector = ffmpegStreamSelector;
}
public Process ForPlayoutItem( public async Task<Process> ForPlayoutItem(
string ffmpegPath, string ffmpegPath,
bool saveReports, bool saveReports,
Channel channel, Channel channel,
@@ -22,10 +29,15 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset start, DateTimeOffset start,
DateTimeOffset now) DateTimeOffset now)
{ {
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
MediaStream audioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings( FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode, channel.StreamingMode,
channel.FFmpegProfile, channel.FFmpegProfile,
version, version,
videoStream,
audioStream,
start, start,
now); now);
@@ -36,7 +48,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithFormatFlags(playbackSettings.FormatFlags) .WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput) .WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek) .WithSeek(playbackSettings.StreamSeek)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, version.VideoCodec); .WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec);
playbackSettings.ScaledSize.Match( playbackSettings.ScaledSize.Match(
scaledSize => scaledSize =>
@@ -51,7 +63,8 @@ namespace ErsatzTV.Core.FFmpeg
} }
builder = builder builder = builder
.WithAlignedAudio(playbackSettings.AudioDuration).WithFilterComplex(); .WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream.Index, audioStream.Index);
}, },
() => () =>
{ {
@@ -61,19 +74,19 @@ namespace ErsatzTV.Core.FFmpeg
.WithDeinterlace(playbackSettings.Deinterlace) .WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution) .WithBlackBars(channel.FFmpegProfile.Resolution)
.WithAlignedAudio(playbackSettings.AudioDuration) .WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(); .WithFilterComplex(videoStream.Index, audioStream.Index);
} }
else if (playbackSettings.Deinterlace) else if (playbackSettings.Deinterlace)
{ {
builder = builder.WithDeinterlace(playbackSettings.Deinterlace) builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration) .WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(); .WithFilterComplex(videoStream.Index, audioStream.Index);
} }
else else
{ {
builder = builder builder = builder
.WithAlignedAudio(playbackSettings.AudioDuration) .WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(); .WithFilterComplex(videoStream.Index, audioStream.Index);
} }
}); });
@@ -0,0 +1,69 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg
{
public class FFmpegStreamSelector : IFFmpegStreamSelector
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILogger<FFmpegStreamSelector> _logger;
public FFmpegStreamSelector(
ILogger<FFmpegStreamSelector> logger,
IConfigElementRepository configElementRepository)
{
_logger = logger;
_configElementRepository = configElementRepository;
}
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
public async Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version)
{
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
string language = channel.PreferredLanguageCode.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language))
{
_logger.LogDebug("Channel {Number} has no preferred language code", channel.Number);
Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>(
ConfigElementKey.FFmpegPreferredLanguageCode);
maybeDefaultLanguage.Match(
lang => language = lang.ToLowerInvariant(),
() =>
{
_logger.LogDebug("FFmpeg has no preferred language code; falling back to {Code}", "eng");
language = "eng";
});
}
var correctLanguage = audioStreams.Filter(
s => string.Equals(
s.Language,
language,
StringComparison.InvariantCultureIgnoreCase)).ToList();
if (correctLanguage.Any())
{
_logger.LogDebug(
"Found {Count} audio streams with preferred language code {Code}; selecting stream with most channels",
correctLanguage.Count,
language);
return correctLanguage.OrderByDescending(s => s.Channels).Head();
}
_logger.LogDebug(
"Unable to find audio stream with preferred language code {Code}; selecting stream with most channels",
language);
return audioStreams.OrderByDescending(s => s.Channels).Head();
}
}
}
@@ -0,0 +1,11 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegStreamSelector
{
Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version);
Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version);
}
}
@@ -12,8 +12,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> RemoveStudio(Studio studio); Task<bool> RemoveStudio(Studio studio);
Task<bool> Update(Domain.Metadata metadata); Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata); Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion); Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true);
Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion); Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming);
Task<Unit> UpdateArtworkPath(Artwork artwork); Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork); Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind); Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
+1 -1
View File
@@ -77,7 +77,7 @@ namespace ErsatzTV.Core.Metadata
string path = version.MediaFiles.Head().Path; string path = version.MediaFiles.Head().Path;
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path)) if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path) || !version.Streams.Any())
{ {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
@@ -44,7 +44,7 @@ namespace ErsatzTV.Core.Metadata
return await maybeProbe.Match( return await maybeProbe.Match(
async ffprobe => async ffprobe =>
{ {
MediaVersion version = ProjectToMediaVersion(ffprobe); MediaVersion version = ProjectToMediaVersion(filePath, ffprobe);
bool result = await ApplyVersionUpdate(mediaItem, version, filePath); bool result = await ApplyVersionUpdate(mediaItem, version, filePath);
return Right<BaseError, bool>(result); return Right<BaseError, bool>(result);
}, },
@@ -68,18 +68,9 @@ namespace ErsatzTV.Core.Metadata
bool durationChange = mediaItemVersion.Duration != version.Duration; bool durationChange = mediaItemVersion.Duration != version.Duration;
mediaItemVersion.DateUpdated = _localFileSystem.GetLastWriteTime(filePath); version.DateUpdated = _localFileSystem.GetLastWriteTime(filePath);
mediaItemVersion.Duration = version.Duration;
mediaItemVersion.AudioCodec = version.AudioCodec;
mediaItemVersion.SampleAspectRatio = version.SampleAspectRatio;
mediaItemVersion.DisplayAspectRatio = version.DisplayAspectRatio;
mediaItemVersion.Width = version.Width;
mediaItemVersion.Height = version.Height;
mediaItemVersion.VideoCodec = version.VideoCodec;
mediaItemVersion.VideoProfile = version.VideoProfile;
mediaItemVersion.VideoScanKind = version.VideoScanKind;
return await _metadataRepository.UpdateLocalStatistics(mediaItemVersion) && durationChange; return await _metadataRepository.UpdateLocalStatistics(mediaItemVersion.Id, version) && durationChange;
} }
private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath) private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
@@ -117,7 +108,7 @@ namespace ErsatzTV.Core.Metadata
}); });
} }
private MediaVersion ProjectToMediaVersion(FFprobe probeOutput) => private MediaVersion ProjectToMediaVersion(string path, FFprobe probeOutput) =>
Optional(probeOutput) Optional(probeOutput)
.Filter(json => json?.format != null && json.streams != null) .Filter(json => json?.format != null && json.streams != null)
.ToValidation<BaseError>("Unable to parse ffprobe output") .ToValidation<BaseError>("Unable to parse ffprobe output")
@@ -125,14 +116,47 @@ namespace ErsatzTV.Core.Metadata
.Match( .Match(
json => json =>
{ {
var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration)); var version = new MediaVersion
{ Name = "Main", DateAdded = DateTime.UtcNow, Streams = new List<MediaStream>() };
var version = new MediaVersion { Name = "Main", Duration = duration }; if (double.TryParse(json.format.duration, out double duration))
FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio");
if (audioStream != null)
{ {
version.AudioCodec = audioStream.codec_name; var seconds = TimeSpan.FromSeconds(duration);
version.Duration = seconds;
}
else
{
_logger.LogWarning(
"Media item at {Path} has a missing or invalid duration {Duration} and will cause scheduling issues",
path,
json.format.duration);
}
foreach (FFprobeStream audioStream in json.streams.Filter(s => s.codec_type == "audio"))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Audio,
Index = audioStream.index,
Codec = audioStream.codec_name,
Profile = (audioStream.profile ?? string.Empty).ToLowerInvariant(),
Channels = audioStream.channels
};
if (audioStream.disposition is not null)
{
stream.Default = audioStream.disposition.@default == 1;
stream.Forced = audioStream.disposition.forced == 1;
}
if (audioStream.tags is not null)
{
stream.Language = audioStream.tags.language;
stream.Title = audioStream.tags.title;
}
version.Streams.Add(stream);
} }
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video"); FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
@@ -142,14 +166,54 @@ namespace ErsatzTV.Core.Metadata
version.DisplayAspectRatio = videoStream.display_aspect_ratio; version.DisplayAspectRatio = videoStream.display_aspect_ratio;
version.Width = videoStream.width; version.Width = videoStream.width;
version.Height = videoStream.height; version.Height = videoStream.height;
version.VideoCodec = videoStream.codec_name;
version.VideoProfile = (videoStream.profile ?? string.Empty).ToLowerInvariant();
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order); version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Video,
Index = videoStream.index,
Codec = videoStream.codec_name,
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant()
};
if (videoStream.disposition is not null)
{
stream.Default = videoStream.disposition.@default == 1;
stream.Forced = videoStream.disposition.forced == 1;
}
version.Streams.Add(stream);
}
foreach (FFprobeStream subtitleStream in json.streams.Filter(s => s.codec_type == "subtitle"))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Subtitle,
Index = subtitleStream.index,
Codec = subtitleStream.codec_name
};
if (subtitleStream.disposition is not null)
{
stream.Default = subtitleStream.disposition.@default == 1;
stream.Forced = subtitleStream.disposition.forced == 1;
}
if (subtitleStream.tags is not null)
{
stream.Language = subtitleStream.tags.language;
}
version.Streams.Add(stream);
} }
return version; return version;
}, },
_ => new MediaVersion { Name = "Main" }); _ => new MediaVersion
{ Name = "Main", DateAdded = DateTime.UtcNow, Streams = new List<MediaStream>() });
private VideoScanKind ScanKindFromFieldOrder(string fieldOrder) => private VideoScanKind ScanKindFromFieldOrder(string fieldOrder) =>
fieldOrder?.ToLowerInvariant() switch fieldOrder?.ToLowerInvariant() switch
@@ -164,17 +228,24 @@ namespace ErsatzTV.Core.Metadata
public record FFprobeFormat(string duration); public record FFprobeFormat(string duration);
public record FFprobeDisposition(int @default, int forced);
public record FFProbeTags(string language, string title);
public record FFprobeStream( public record FFprobeStream(
int index, int index,
string codec_name, string codec_name,
string profile, string profile,
string codec_type, string codec_type,
int channels,
int width, int width,
int height, int height,
string sample_aspect_ratio, string sample_aspect_ratio,
string display_aspect_ratio, string display_aspect_ratio,
string field_order, string field_order,
string r_frame_rate); string r_frame_rate,
FFprobeDisposition disposition,
FFProbeTags tags);
// ReSharper restore InconsistentNaming // ReSharper restore InconsistentNaming
} }
} }
@@ -105,8 +105,7 @@ namespace ErsatzTV.Core.Plex
MediaVersion existingVersion = existing.MediaVersions.Head(); MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
{ {
Either<BaseError, MediaVersion> maybeStatistics = Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token); await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
@@ -114,11 +113,11 @@ namespace ErsatzTV.Core.Plex
await maybeStatistics.Match( await maybeStatistics.Match(
async mediaVersion => async mediaVersion =>
{ {
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio ?? "1:1"; existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind; existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = incomingVersion.DateUpdated; existingVersion.DateUpdated = mediaVersion.DateUpdated;
await _metadataRepository.UpdatePlexStatistics(existingVersion); await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
}, },
_ => Task.CompletedTask); _ => Task.CompletedTask);
} }
@@ -301,8 +301,7 @@ namespace ErsatzTV.Core.Plex
MediaVersion existingVersion = existing.MediaVersions.Head(); MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
{ {
Either<BaseError, MediaVersion> maybeStatistics = Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token); await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
@@ -310,11 +309,11 @@ namespace ErsatzTV.Core.Plex
await maybeStatistics.Match( await maybeStatistics.Match(
async mediaVersion => async mediaVersion =>
{ {
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio ?? "1:1"; existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind; existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = incomingVersion.DateUpdated; existingVersion.DateUpdated = mediaVersion.DateUpdated;
await _metadataRepository.UpdatePlexStatistics(existingVersion); await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
}, },
_ => Task.CompletedTask); _ => Task.CompletedTask);
} }
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class MediaStreamConfiguration : IEntityTypeConfiguration<MediaStream>
{
public void Configure(EntityTypeBuilder<MediaStream> builder) => builder.ToTable("MediaStream");
}
}
@@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.WithOne(f => f.MediaVersion) .WithOne(f => f.MediaVersion)
.HasForeignKey(f => f.MediaVersionId) .HasForeignKey(f => f.MediaVersionId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasMany(v => v.Streams)
.WithOne(s => s.MediaVersion)
.HasForeignKey(s => s.MediaVersionId)
.OnDelete(DeleteBehavior.Cascade);
} }
} }
} }
@@ -1,11 +1,13 @@
using System; using System;
using System.Data; using System.Data;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dapper; using Dapper;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Data.Repositories namespace ErsatzTV.Infrastructure.Data.Repositories
{ {
@@ -44,15 +46,66 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return await dbContext.SaveChangesAsync() > 0; return await dbContext.SaveChangesAsync() > 0;
} }
public async Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion) public async Task<bool> UpdateLocalStatistics(
int mediaVersionId,
MediaVersion incoming,
bool updateVersion = true)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
dbContext.Entry(mediaVersion).State = EntityState.Modified; Option<MediaVersion> maybeVersion = await dbContext.MediaVersions
return await dbContext.SaveChangesAsync() > 0; .Include(v => v.Streams)
.OrderBy(v => v.Id)
.SingleOrDefaultAsync(v => v.Id == mediaVersionId)
.Map(Optional);
return await maybeVersion.Match(
async existing =>
{
if (updateVersion)
{
existing.DateUpdated = incoming.DateUpdated;
existing.Duration = incoming.Duration;
existing.SampleAspectRatio = incoming.SampleAspectRatio;
existing.DisplayAspectRatio = incoming.DisplayAspectRatio;
existing.Width = incoming.Width;
existing.Height = incoming.Height;
existing.VideoScanKind = incoming.VideoScanKind;
}
var toAdd = incoming.Streams.Filter(s => existing.Streams.All(es => es.Index != s.Index)).ToList();
var toRemove = existing.Streams.Filter(es => incoming.Streams.All(s => s.Index != es.Index))
.ToList();
var toUpdate = incoming.Streams.Except(toAdd).ToList();
// add
existing.Streams.AddRange(toAdd);
// remove
existing.Streams.RemoveAll(s => toRemove.Contains(s));
// update
foreach (MediaStream incomingStream in toUpdate)
{
MediaStream existingStream = existing.Streams.First(s => s.Index == incomingStream.Index);
existingStream.Codec = incomingStream.Codec;
existingStream.Profile = incomingStream.Profile;
existingStream.MediaStreamKind = incomingStream.MediaStreamKind;
existingStream.Language = incomingStream.Language;
existingStream.Channels = incomingStream.Channels;
existingStream.Title = incomingStream.Title;
existingStream.Default = incomingStream.Default;
existingStream.Forced = incomingStream.Forced;
}
return await dbContext.SaveChangesAsync() > 0;
},
() => Task.FromResult(false));
} }
public Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion) => public async Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming)
_dbConnection.ExecuteAsync( {
bool updatedVersion = await _dbConnection.ExecuteAsync(
@"UPDATE MediaVersion SET @"UPDATE MediaVersion SET
SampleAspectRatio = @SampleAspectRatio, SampleAspectRatio = @SampleAspectRatio,
VideoScanKind = @VideoScanKind, VideoScanKind = @VideoScanKind,
@@ -60,12 +113,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE Id = @MediaVersionId", WHERE Id = @MediaVersionId",
new new
{ {
mediaVersion.SampleAspectRatio, incoming.SampleAspectRatio,
mediaVersion.VideoScanKind, incoming.VideoScanKind,
mediaVersion.DateUpdated, incoming.DateUpdated,
MediaVersionId = mediaVersion.Id MediaVersionId = mediaVersionId
}).Map(result => result > 0); }).Map(result => result > 0);
return await UpdateLocalStatistics(mediaVersionId, incoming, false) || updatedVersion;
}
public Task<Unit> UpdateArtworkPath(Artwork artwork) => public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
_dbConnection.ExecuteAsync( _dbConnection.ExecuteAsync(
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id", "UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
@@ -64,6 +64,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path) .OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path); .SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -91,6 +93,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.LibraryPath) .Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
@@ -223,7 +227,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
MediaFiles = new List<MediaFile> MediaFiles = new List<MediaFile>
{ {
new() { Path = path } new() { Path = path }
} },
Streams = new List<MediaStream>()
} }
} }
}; };
@@ -57,8 +57,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mi => (mi as Episode).MediaVersions) .ThenInclude(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions) .ThenInclude(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams)
.AsNoTracking() .AsNoTracking()
.SingleOrDefaultAsync() .SingleOrDefaultAsync()
.Map(Optional); .Map(Optional);
@@ -283,6 +283,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(em => em.Artwork) .ThenInclude(em => em.Artwork)
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path) .OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path); .SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -414,6 +416,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key); .SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -582,7 +586,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
MediaFiles = new List<MediaFile> MediaFiles = new List<MediaFile>
{ {
new() { Path = path } new() { Path = path }
} },
Streams = new List<MediaStream>()
} }
} }
}; };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MediaStream : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"MediaStream",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Index = table.Column<int>("INTEGER", nullable: false),
Codec = table.Column<string>("TEXT", nullable: true),
Profile = table.Column<string>("TEXT", nullable: true),
MediaStreamKind = table.Column<int>("INTEGER", nullable: false),
Language = table.Column<string>("TEXT", nullable: true),
Channels = table.Column<int>("INTEGER", nullable: false),
Title = table.Column<string>("TEXT", nullable: true),
Default = table.Column<bool>("INTEGER", nullable: false),
Forced = table.Column<bool>("INTEGER", nullable: false),
MediaVersionId = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MediaStream", x => x.Id);
table.ForeignKey(
"FK_MediaStream_MediaVersion_MediaVersionId",
x => x.MediaVersionId,
"MediaVersion",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_MediaStream_MediaVersionId",
"MediaStream",
"MediaVersionId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"MediaStream");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ChannelPreferredLanguageCode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.AddColumn<string>(
"PreferredLanguageCode",
"Channel",
"TEXT",
nullable: true);
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"PreferredLanguageCode",
"Channel");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_LibraryLastScan_MediaStream : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -83,6 +83,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Number") b.Property<string>("Number")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("PreferredLanguageCode")
.HasColumnType("TEXT");
b.Property<int>("StreamingMode") b.Property<int>("StreamingMode")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -419,6 +422,51 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaSource"); b.ToTable("MediaSource");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaStream",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasColumnType("TEXT");
b.Property<bool>("Default")
.HasColumnType("INTEGER");
b.Property<bool>("Forced")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("MediaStreamKind")
.HasColumnType("INTEGER");
b.Property<int>("MediaVersionId")
.HasColumnType("INTEGER");
b.Property<string>("Profile")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaVersionId");
b.ToTable("MediaStream");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaVersion", "ErsatzTV.Core.Domain.MediaVersion",
b => b =>
@@ -1299,6 +1347,19 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("LibraryPath"); b.Navigation("LibraryPath");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaStream",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion")
.WithMany("Streams")
.HasForeignKey("MediaVersionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaVersion");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaVersion", "ErsatzTV.Core.Domain.MediaVersion",
b => b =>
@@ -1822,7 +1883,14 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => { b.Navigation("Libraries"); }); 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.MediaVersion",
b =>
{
b.Navigation("MediaFiles");
b.Navigation("Streams");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata", "ErsatzTV.Core.Domain.MovieMetadata",
@@ -3,7 +3,14 @@
public class PlexStreamResponse public class PlexStreamResponse
{ {
public int Id { get; set; } public int Id { get; set; }
public int Index { get; set; }
public bool Default { get; set; }
public bool Forced { get; set; }
public string LanguageCode { get; set; }
public int StreamType { get; set; } public int StreamType { get; set; }
public string Codec { get; set; }
public string Profile { get; set; }
public int Channels { get; set; }
public bool Anamorphic { get; set; } public bool Anamorphic { get; set; }
public string PixelAspectRatio { get; set; } public string PixelAspectRatio { get; set; }
public string ScanType { get; set; } public string ScanType { get; set; }
@@ -227,10 +227,8 @@ namespace ErsatzTV.Infrastructure.Plex
Duration = TimeSpan.FromMilliseconds(media.Duration), Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width, Width = media.Width,
Height = media.Height, Height = media.Height,
AudioCodec = media.AudioCodec,
VideoCodec = media.VideoCodec,
VideoProfile = media.VideoProfile,
// specifically omit sample aspect ratio // specifically omit sample aspect ratio
DateAdded = dateAdded,
DateUpdated = lastWriteTime, DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile> MediaFiles = new List<MediaFile>
{ {
@@ -240,7 +238,8 @@ namespace ErsatzTV.Infrastructure.Plex
Key = part.Key, Key = part.Key,
Path = part.File Path = part.File
} }
} },
Streams = new List<MediaStream>()
}; };
var movie = new PlexMovie var movie = new PlexMovie
@@ -255,18 +254,72 @@ namespace ErsatzTV.Infrastructure.Plex
private Option<MediaVersion> ProjectToMediaVersion(PlexMetadataResponse response) private Option<MediaVersion> ProjectToMediaVersion(PlexMetadataResponse response)
{ {
Option<PlexStreamResponse> maybeStream = List<PlexStreamResponse> streams = response.Media.Head().Part.Head().Stream;
response.Media.Head().Part.Head().Stream.Find(s => s.StreamType == 1); DateTime dateUpdated = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
return maybeStream.Map( Option<PlexStreamResponse> maybeVideoStream = streams.Find(s => s.StreamType == 1);
stream => new MediaVersion return maybeVideoStream.Map(
videoStream =>
{ {
SampleAspectRatio = stream.PixelAspectRatio, var version = new MediaVersion
VideoScanKind = stream.ScanType switch
{ {
"interlaced" => VideoScanKind.Interlaced, SampleAspectRatio = videoStream.PixelAspectRatio ?? "1:1",
"progressive" => VideoScanKind.Progressive, VideoScanKind = videoStream.ScanType switch
_ => VideoScanKind.Unknown {
"interlaced" => VideoScanKind.Interlaced,
"progressive" => VideoScanKind.Progressive,
_ => VideoScanKind.Unknown
},
Streams = new List<MediaStream>(),
DateUpdated = dateUpdated
};
version.Streams.Add(
new MediaStream
{
MediaStreamKind = MediaStreamKind.Video,
Index = videoStream.Index,
Codec = videoStream.Codec,
Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(),
Default = videoStream.Default,
Language = videoStream.LanguageCode,
Forced = videoStream.Forced
});
foreach (PlexStreamResponse audioStream in streams.Filter(s => s.StreamType == 2))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Audio,
Index = audioStream.Index,
Codec = audioStream.Codec,
Profile = (audioStream.Profile ?? string.Empty).ToLowerInvariant(),
Channels = audioStream.Channels,
Default = audioStream.Default,
Forced = audioStream.Forced,
Language = audioStream.LanguageCode
};
version.Streams.Add(stream);
} }
foreach (PlexStreamResponse subtitleStream in streams.Filter(s => s.StreamType == 3))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Subtitle,
Index = subtitleStream.Index,
Codec = subtitleStream.Codec,
Default = subtitleStream.Default,
Forced = subtitleStream.Forced,
Language = subtitleStream.LanguageCode
};
version.Streams.Add(stream);
}
return version;
}); });
} }
@@ -436,10 +489,7 @@ namespace ErsatzTV.Infrastructure.Plex
Duration = TimeSpan.FromMilliseconds(media.Duration), Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width, Width = media.Width,
Height = media.Height, Height = media.Height,
AudioCodec = media.AudioCodec, DateAdded = dateAdded,
VideoCodec = media.VideoCodec,
VideoProfile = media.VideoProfile,
// specifically omit sample aspect ratio
DateUpdated = lastWriteTime, DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile> MediaFiles = new List<MediaFile>
{ {
@@ -449,7 +499,9 @@ namespace ErsatzTV.Infrastructure.Plex
Key = part.Key, Key = part.Key,
Path = part.File Path = part.File
} }
} },
// specifically omit stream details
Streams = new List<MediaStream>()
}; };
var episode = new PlexEpisode var episode = new PlexEpisode
+1 -1
View File
@@ -27,7 +27,7 @@ namespace ErsatzTV.Controllers
_mediator = mediator; _mediator = mediator;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
} }
[HttpGet("/iptv/artwork/posters/{fileName}")] [HttpGet("/iptv/artwork/posters/{fileName}")]
[HttpGet("/artwork/posters/{fileName}")] [HttpGet("/artwork/posters/{fileName}")]
public async Task<IActionResult> GetPoster(string fileName) public async Task<IActionResult> GetPoster(string fileName)
+2
View File
@@ -34,6 +34,7 @@
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem> <MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTextField Class="mt-3" Label="Preferred Language Code" @bind-Value="_model.PreferredLanguageCode" For="@(() => _model.PreferredLanguageCode)"/>
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center"> <MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6"> <MudItem xs="6">
<InputFile id="fileInput" OnChange="UploadLogo" hidden/> <InputFile id="fileInput" OnChange="UploadLogo" hidden/>
@@ -90,6 +91,7 @@
_model.FFmpegProfileId = channelViewModel.FFmpegProfileId; _model.FFmpegProfileId = channelViewModel.FFmpegProfileId;
_model.Logo = channelViewModel.Logo; _model.Logo = channelViewModel.Logo;
_model.StreamingMode = channelViewModel.StreamingMode; _model.StreamingMode = channelViewModel.StreamingMode;
_model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode;
}, },
() => NavigationManager.NavigateTo("404")); () => NavigationManager.NavigateTo("404"));
} }
+8 -5
View File
@@ -15,9 +15,10 @@
<ColGroup> <ColGroup>
<col style="width: 60px;"/> <col style="width: 60px;"/>
<col/> <col/>
<col style="width: 20%"/> <col style="width: 15%"/>
<col style="width: 20%"/> <col style="width: 15%"/>
<col style="width: 20%"/> <col style="width: 15%"/>
<col style="width: 15%"/>
<col style="width: 120px;"/> <col style="width: 120px;"/>
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
@@ -28,7 +29,8 @@
<MudTh> <MudTh>
<MudTableSortLabel SortBy="new Func<ChannelViewModel, object>(x => x.Name)">Name</MudTableSortLabel> <MudTableSortLabel SortBy="new Func<ChannelViewModel, object>(x => x.Name)">Name</MudTableSortLabel>
</MudTh> </MudTh>
<MudTh>Streaming Mode</MudTh> <MudTh>Language</MudTh>
<MudTh>Mode</MudTh>
<MudTh>FFmpeg Profile</MudTh> <MudTh>FFmpeg Profile</MudTh>
<MudTh/> <MudTh/>
</HeaderContent> </HeaderContent>
@@ -41,7 +43,8 @@
} }
</MudTd> </MudTd>
<MudTd DataLabel="Name">@context.Name</MudTd> <MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Streaming Mode">@context.StreamingMode</MudTd> <MudTd DataLabel="Language">@context.PreferredLanguageCode</MudTd>
<MudTd DataLabel="Mode">@(context.StreamingMode == StreamingMode.TransportStream ? "TS" : "HLS")</MudTd>
<MudTd DataLabel="FFmpeg Profile"> <MudTd DataLabel="FFmpeg Profile">
@if (context.StreamingMode == StreamingMode.TransportStream) @if (context.StreamingMode == StreamingMode.TransportStream)
{ {
+17
View File
@@ -3,6 +3,7 @@
@using ErsatzTV.Application.FFmpegProfiles.Commands @using ErsatzTV.Application.FFmpegProfiles.Commands
@using ErsatzTV.Application.FFmpegProfiles.Queries @using ErsatzTV.Application.FFmpegProfiles.Queries
@using Unit = LanguageExt.Unit @using Unit = LanguageExt.Unit
@using System.Globalization
@inject IDialogService Dialog @inject IDialogService Dialog
@inject IMediator Mediator @inject IMediator Mediator
@inject ILogger<FFmpeg> Logger @inject ILogger<FFmpeg> Logger
@@ -29,6 +30,9 @@
} }
</MudSelect> </MudSelect>
</MudElement> </MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="string" Label="Preferred Language Code" @bind-Value="_ffmpegSettings.PreferredLanguageCode" Validation="@(new Func<string, string>(ValidateLanguageCode))" Required="true" RequiredError="Preferred Language Code is required!"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3"> <MudElement HtmlTag="div" Class="mt-3">
<MudSwitch T="bool" <MudSwitch T="bool"
Label="Save troubleshooting reports to disk" Label="Save troubleshooting reports to disk"
@@ -133,6 +137,19 @@
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null; private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
private static string ValidateLanguageCode(string languageCode)
{
if (string.IsNullOrWhiteSpace(languageCode))
{
return null;
}
Option<CultureInfo> culture = CultureInfo.GetCultures(CultureTypes.NeutralCultures)
.FirstOrDefault(ci => string.Equals(ci.ThreeLetterISOLanguageName, languageCode, StringComparison.OrdinalIgnoreCase));
return culture.IsNone ? "Preferred language code is invalid" : null;
}
private async Task LoadFFmpegProfilesAsync() => private async Task LoadFFmpegProfilesAsync() =>
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles()); _ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles());
+2 -1
View File
@@ -179,7 +179,6 @@ namespace ErsatzTV
private void CustomServices(IServiceCollection services) private void CustomServices(IServiceCollection services)
{ {
services.AddSingleton<FFmpegPlaybackSettingsCalculator>(); services.AddSingleton<FFmpegPlaybackSettingsCalculator>();
services.AddSingleton<FFmpegProcessService>();
services.AddSingleton<IPlexSecretStore, PlexSecretStore>(); services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton? services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
services.AddSingleton<IEntityLocker, EntityLocker>(); services.AddSingleton<IEntityLocker, EntityLocker>();
@@ -216,6 +215,8 @@ namespace ErsatzTV
services.AddScoped<ISearchIndex, SearchIndex>(); services.AddScoped<ISearchIndex, SearchIndex>();
services.AddScoped<IRuntimeInfo, RuntimeInfo>(); services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>(); services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<FFmpegProcessService>();
services.AddHostedService<PlexService>(); services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>(); services.AddHostedService<FFmpegLocatorService>();
@@ -1,4 +1,7 @@
using ErsatzTV.Core.Domain; using System;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.ViewModels; using ErsatzTV.ViewModels;
using FluentValidation; using FluentValidation;
@@ -13,6 +16,17 @@ namespace ErsatzTV.Validators
RuleFor(x => x.Name).NotEmpty(); RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.FFmpegProfileId).GreaterThan(0); RuleFor(x => x.FFmpegProfileId).GreaterThan(0);
RuleFor(x => x.PreferredLanguageCode)
.Must(
languageCode => CultureInfo.GetCultures(CultureTypes.NeutralCultures)
.Any(
ci => string.Equals(
ci.ThreeLetterISOLanguageName,
languageCode,
StringComparison.OrdinalIgnoreCase)))
.When(vm => !string.IsNullOrWhiteSpace(vm.PreferredLanguageCode))
.WithMessage("Preferred language code is invalid");
} }
} }
} }
@@ -9,6 +9,7 @@ namespace ErsatzTV.ViewModels
public string Name { get; set; } public string Name { get; set; }
public string Number { get; set; } public string Number { get; set; }
public int FFmpegProfileId { get; set; } public int FFmpegProfileId { get; set; }
public string PreferredLanguageCode { get; set; }
public string Logo { get; set; } public string Logo { get; set; }
public StreamingMode StreamingMode { get; set; } public StreamingMode StreamingMode { get; set; }
@@ -19,6 +20,7 @@ namespace ErsatzTV.ViewModels
Number, Number,
FFmpegProfileId, FFmpegProfileId,
Logo, Logo,
PreferredLanguageCode,
StreamingMode); StreamingMode);
public CreateChannel ToCreate() => public CreateChannel ToCreate() =>
@@ -27,6 +29,7 @@ namespace ErsatzTV.ViewModels
Number, Number,
FFmpegProfileId, FFmpegProfileId,
Logo, Logo,
PreferredLanguageCode,
StreamingMode); StreamingMode);
} }
} }