Browse Source

scanner refactoring and other cleanup (#1082)

* move subtitles provider into scanner

* move more stuff into scanner

* move nfo into scanner

* add scan subcommand

* fix a bunch of nfo build warnings

* more subcommands

* fix warnings

* cleanup logging

* remove unused code

* cleanup old ffmpeg stuff

* rename complex filter

* refactor wrapped segmenter
pull/1083/head
Jason Dove 4 years ago committed by GitHub
parent
commit
9479806cb0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      ErsatzTV.Application/Emby/Commands/CallEmbyLibraryScannerHandler.cs
  2. 3
      ErsatzTV.Application/Jellyfin/Commands/CallJellyfinLibraryScannerHandler.cs
  3. 3
      ErsatzTV.Application/MediaSources/Commands/CallLocalLibraryScannerHandler.cs
  4. 3
      ErsatzTV.Application/MediaSources/LocalMediaSourceViewModel.cs
  5. 3
      ErsatzTV.Application/Plex/Commands/CallPlexLibraryScannerHandler.cs
  6. 2
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs
  7. 9
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  8. 777
      ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs
  9. 337
      ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs
  10. 33
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  11. 535
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  12. 31
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  13. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  14. 1
      ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs
  15. 8
      ErsatzTV.Core/Interfaces/Metadata/Nfo/IArtistNfoReader.cs
  16. 8
      ErsatzTV.Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs
  17. 8
      ErsatzTV.Core/Interfaces/Metadata/Nfo/IMovieNfoReader.cs
  18. 8
      ErsatzTV.Core/Interfaces/Metadata/Nfo/IMusicVideoNfoReader.cs
  19. 8
      ErsatzTV.Core/Interfaces/Metadata/Nfo/IOtherVideoNfoReader.cs
  20. 8
      ErsatzTV.Core/Interfaces/Metadata/Nfo/ITvShowNfoReader.cs
  21. 1
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  22. 1
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  23. 30
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  24. 2
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  25. 18
      ErsatzTV.Core/Metadata/Nfo/ActorNfo.cs
  26. 25
      ErsatzTV.Core/Metadata/Nfo/ArtistNfo.cs
  27. 52
      ErsatzTV.Core/Metadata/Nfo/MovieNfo.cs
  28. 37
      ErsatzTV.Core/Metadata/Nfo/MusicVideoNfo.cs
  29. 52
      ErsatzTV.Core/Metadata/Nfo/OtherVideoNfo.cs
  30. 40
      ErsatzTV.Core/Metadata/Nfo/TvShowEpisodeNfo.cs
  31. 50
      ErsatzTV.Core/Metadata/Nfo/TvShowNfo.cs
  32. 15
      ErsatzTV.Core/Metadata/Nfo/UniqueIdNfo.cs
  33. 34
      ErsatzTV.Core/Metadata/SortTitle.cs
  34. 3
      ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
  35. 25
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  36. 1
      ErsatzTV.FFmpeg/CommandGenerator.cs
  37. 286
      ErsatzTV.FFmpeg/Filter/ComplexFilter.cs
  38. 252
      ErsatzTV.FFmpeg/Filter/NewComplexFilter.cs
  39. 6
      ErsatzTV.FFmpeg/Option/MapAllStreamsOutputOption.cs
  40. 13
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatMpegTs.cs
  41. 1
      ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs
  42. 31
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  43. 14
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  44. 8
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  45. 13
      ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs
  46. 12
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  47. 16
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  48. 14
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  49. 18
      ErsatzTV.Scanner.Tests/Core/Metadata/LocalStatisticsProviderTests.cs
  50. 6
      ErsatzTV.Scanner.Tests/Core/Metadata/LocalSubtitlesProviderTests.cs
  51. 1
      ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs
  52. 5
      ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/ArtistNfoReaderTests.cs
  53. 53
      ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/EpisodeNfoReaderTests.cs
  54. 7
      ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/MovieNfoReaderTests.cs
  55. 5
      ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/MusicVideoNfoReaderTests.cs
  56. 5
      ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/OtherVideoNfoReaderTests.cs
  57. 35
      ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/ShowNfoReaderTests.cs
  58. 12
      ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
  59. 0
      ErsatzTV.Scanner.Tests/Resources/Nfo/ArtistInvalidCharacters1.nfo
  60. 0
      ErsatzTV.Scanner.Tests/Resources/Nfo/ArtistInvalidCharacters2.nfo
  61. 0
      ErsatzTV.Scanner.Tests/Resources/Nfo/EpisodeInvalidCharacters.nfo
  62. 2
      ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  63. 4
      ErsatzTV.Scanner/Core/Emby/EmbyCollectionScanner.cs
  64. 2
      ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs
  65. 2
      ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs
  66. 2
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  67. 5
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalStatisticsProvider.cs
  68. 2
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalSubtitlesProvider.cs
  69. 5
      ErsatzTV.Scanner/Core/Interfaces/Metadata/IMovieFolderScanner.cs
  70. 5
      ErsatzTV.Scanner/Core/Interfaces/Metadata/IMusicVideoFolderScanner.cs
  71. 5
      ErsatzTV.Scanner/Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs
  72. 5
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ISongFolderScanner.cs
  73. 5
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ITelevisionFolderScanner.cs
  74. 9
      ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IArtistNfoReader.cs
  75. 9
      ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs
  76. 9
      ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IMovieNfoReader.cs
  77. 9
      ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IMusicVideoNfoReader.cs
  78. 9
      ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IOtherVideoNfoReader.cs
  79. 9
      ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IShowNfoReader.cs
  80. 4
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinCollectionScanner.cs
  81. 2
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs
  82. 2
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  83. 2
      ErsatzTV.Scanner/Core/Metadata/FolderEtag.cs
  84. 1
      ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs
  85. 79
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  86. 93
      ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs
  87. 17
      ErsatzTV.Scanner/Core/Metadata/LocalSubtitlesProvider.cs
  88. 8
      ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
  89. 8
      ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  90. 2
      ErsatzTV.Scanner/Core/Metadata/MetadataSongTag.cs
  91. 1
      ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs
  92. 1
      ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs
  93. 9
      ErsatzTV.Scanner/Core/Metadata/Nfo/ActorNfo.cs
  94. 18
      ErsatzTV.Scanner/Core/Metadata/Nfo/ArtistNfo.cs
  95. 14
      ErsatzTV.Scanner/Core/Metadata/Nfo/ArtistNfoReader.cs
  96. 24
      ErsatzTV.Scanner/Core/Metadata/Nfo/EpisodeNfo.cs
  97. 23
      ErsatzTV.Scanner/Core/Metadata/Nfo/EpisodeNfoReader.cs
  98. 31
      ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfo.cs
  99. 18
      ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfoReader.cs
  100. 23
      ErsatzTV.Scanner/Core/Metadata/Nfo/MusicVideoNfo.cs
  101. Some files were not shown because too many files have changed in this diff Show More

3
ErsatzTV.Application/Emby/Commands/CallEmbyLibraryScannerHandler.cs

@ -42,8 +42,7 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler,
{ {
var arguments = new List<string> var arguments = new List<string>
{ {
"--emby", "scan-emby", request.EmbyLibraryId.ToString()
request.EmbyLibraryId.ToString()
}; };
if (request.ForceScan) if (request.ForceScan)

3
ErsatzTV.Application/Jellyfin/Commands/CallJellyfinLibraryScannerHandler.cs

@ -42,8 +42,7 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
{ {
var arguments = new List<string> var arguments = new List<string>
{ {
"--jellyfin", "scan-jellyfin", request.JellyfinLibraryId.ToString()
request.JellyfinLibraryId.ToString()
}; };
if (request.ForceScan) if (request.ForceScan)

3
ErsatzTV.Application/MediaSources/Commands/CallLocalLibraryScannerHandler.cs

@ -40,8 +40,7 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler,
{ {
var arguments = new List<string> var arguments = new List<string>
{ {
"--local", "scan-local", request.LibraryId.ToString()
request.LibraryId.ToString()
}; };
if (request.ForceScan) if (request.ForceScan)

3
ErsatzTV.Application/MediaSources/LocalMediaSourceViewModel.cs

@ -1,3 +0,0 @@
namespace ErsatzTV.Application.MediaSources;
public record LocalMediaSourceViewModel(int Id) : MediaSourceViewModel(Id, "Local");

3
ErsatzTV.Application/Plex/Commands/CallPlexLibraryScannerHandler.cs

@ -42,8 +42,7 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
{ {
var arguments = new List<string> var arguments = new List<string>
{ {
"--plex", "scan-plex", request.PlexLibraryId.ToString()
request.PlexLibraryId.ToString()
}; };
if (request.ForceScan) if (request.ForceScan)

2
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs

@ -30,7 +30,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports) .GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false)); .Map(result => result.IfNone(false));
Command process = _ffmpegProcessService.WrapSegmenter( Command process = await _ffmpegProcessService.WrapSegmenter(
ffmpegPath, ffmpegPath,
saveReports, saveReports,
channel, channel,

9
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -38,21 +38,12 @@
<Content Include="Resources\ErsatzTV.png"> <Content Include="Resources\ErsatzTV.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Resources\Nfo\ArtistInvalidCharacters1.nfo">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\Nfo\ArtistInvalidCharacters2.nfo">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\test.sup"> <Content Include="Resources\test.sup">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Resources\test.srt"> <Content Include="Resources\test.srt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Resources\Nfo\EpisodeInvalidCharacters.nfo">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

777
ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs

@ -1,777 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.FFmpeg.State;
using FluentAssertions;
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<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsNone.Should().BeTrue();
}
[Test]
public void Should_Return_Audio_Filter_With_AudioDuration()
{
var duration = TimeSpan.FromMilliseconds(1000.1);
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("0:0");
});
}
[Test]
// this needs to be a culture where '.' is a group separator
[SetCulture("it-IT")]
public void Should_Return_Audio_Filter_With_AudioDuration_Decimal()
{
var duration = TimeSpan.FromMilliseconds(1000.1);
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("0:0");
});
}
[Test]
public void Should_Return_Audio_And_Video_Filter()
{
var duration = TimeSpan.FromMinutes(54);
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration)
.WithDeinterlace(true);
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(
$"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:0]yadif=1[v]");
filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("[v]");
});
}
[Test]
[TestCase(true, false, false, "[0:0]yadif=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:0]yadif=1,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
[TestCase(
true,
true,
true,
"[0:0]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:0]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
[TestCase(false, false, true, "[0:0]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
[TestCase(
false,
true,
true,
"[0:0]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<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase(
false,
false,
false,
WatermarkLocation.BottomLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=H-h-54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
WatermarkLocation.BottomRight,
false,
100,
"[0:0][1:v]overlay=x=W-w-134:y=H-h-54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
WatermarkLocation.TopLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
WatermarkLocation.TopRight,
false,
100,
"[0:0][1:v]overlay=x=W-w-134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
true,
WatermarkLocation.TopLeft,
false,
100,
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,fade=in:st=300:d=1:alpha=1:enable='between(t,0,314)',fade=out:st=315:d=1:alpha=1:enable='between(t,301,899)',fade=in:st=900:d=1:alpha=1:enable='between(t,316,914)',fade=out:st=915:d=1:alpha=1:enable='between(t,901,1499)',fade=in:st=1500:d=1:alpha=1:enable='between(t,916,1514)',fade=out:st=1515:d=1:alpha=1:enable='between(t,1501,2099)',fade=in:st=2100:d=1:alpha=1:enable='between(t,1516,2114)',fade=out:st=2115:d=1:alpha=1:enable='between(t,2101,2699)',fade=in:st=2700:d=1:alpha=1:enable='between(t,2116,2714)',fade=out:st=2715:d=1:alpha=1:enable='between(t,2701,3300)'[wmp];[0:0][wmp]overlay=x=134:y=54,format=nv12[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
WatermarkLocation.TopLeft,
true,
100,
"[1:v]scale=384:-1[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
WatermarkLocation.TopLeft,
false,
90,
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.90[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
true,
false,
WatermarkLocation.TopLeft,
false,
100,
"[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
true,
false,
WatermarkLocation.TopLeft,
true,
100,
"[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
true,
true,
false,
WatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
"[a]",
"[v]")]
[TestCase(
true,
false,
false,
WatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0][1:v]overlay=x=134:y=54[v]",
"[a]",
"[v]")]
public void Should_Return_Watermark(
bool alignAudio,
bool deinterlace,
bool intermittent,
WatermarkLocation location,
bool scaled,
int opacity,
string expectedVideoFilter,
string expectedAudioLabel,
string expectedVideoLabel)
{
var watermark = new ChannelWatermark
{
Mode = intermittent
? ChannelWatermarkMode.Intermittent
: ChannelWatermarkMode.Permanent,
DurationSeconds = intermittent ? 15 : 0,
FrequencyMinutes = intermittent ? 10 : 0,
Location = location,
Size = scaled ? WatermarkSize.Scaled : WatermarkSize.ActualSize,
WidthPercent = scaled ? 20 : 0,
Opacity = opacity,
HorizontalMarginPercent = 7,
VerticalMarginPercent = 5
};
Option<List<FadePoint>> maybeFadePoints = watermark.Mode == ChannelWatermarkMode.Intermittent
? Some(
WatermarkCalculator.CalculateFadePoints(
new DateTimeOffset(2022, 01, 31, 12, 25, 0, TimeSpan.FromHours(-5)),
TimeSpan.Zero,
TimeSpan.FromMinutes(55),
TimeSpan.Zero,
watermark.FrequencyMinutes,
watermark.DurationSeconds))
: None;
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithWatermark(
Some(watermark),
maybeFadePoints,
new Resolution { Width = 1920, Height = 1080 },
None)
.WithDeinterlace(deinterlace)
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be(expectedAudioLabel);
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase(
false,
false,
false,
WatermarkLocation.BottomLeft,
false,
100,
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=H-h-54[v]",
"0:1",
"[v]",
false)]
[TestCase(
false,
false,
false,
WatermarkLocation.BottomLeft,
false,
100,
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=H-h-54,hwupload[v]",
"0:1",
"[v]",
true)]
[TestCase(
false,
false,
true,
WatermarkLocation.TopLeft,
false,
100,
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,fade=in:st=300:d=1:alpha=1:enable='between(t,0,314)',fade=out:st=315:d=1:alpha=1:enable='between(t,301,899)',fade=in:st=900:d=1:alpha=1:enable='between(t,316,914)',fade=out:st=915:d=1:alpha=1:enable='between(t,901,1499)',fade=in:st=1500:d=1:alpha=1:enable='between(t,916,1514)',fade=out:st=1515:d=1:alpha=1:enable='between(t,1501,2099)',fade=in:st=2100:d=1:alpha=1:enable='between(t,1516,2114)',fade=out:st=2115:d=1:alpha=1:enable='between(t,2101,2699)',fade=in:st=2700:d=1:alpha=1:enable='between(t,2116,2714)',fade=out:st=2715:d=1:alpha=1:enable='between(t,2701,3300)',hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
"0:1",
"[v]",
false)]
[TestCase(
false,
false,
true,
WatermarkLocation.TopLeft,
false,
100,
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,fade=in:st=300:d=1:alpha=1:enable='between(t,0,314)',fade=out:st=315:d=1:alpha=1:enable='between(t,301,899)',fade=in:st=900:d=1:alpha=1:enable='between(t,316,914)',fade=out:st=915:d=1:alpha=1:enable='between(t,901,1499)',fade=in:st=1500:d=1:alpha=1:enable='between(t,916,1514)',fade=out:st=1515:d=1:alpha=1:enable='between(t,1501,2099)',fade=in:st=2100:d=1:alpha=1:enable='between(t,1516,2114)',fade=out:st=2115:d=1:alpha=1:enable='between(t,2101,2699)',fade=in:st=2700:d=1:alpha=1:enable='between(t,2116,2714)',fade=out:st=2715:d=1:alpha=1:enable='between(t,2701,3300)',hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
"0:1",
"[v]",
true)]
[TestCase(
false,
false,
false,
WatermarkLocation.TopLeft,
true,
100,
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,scale=384:-1,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
"0:1",
"[v]",
false)]
[TestCase(
false,
false,
false,
WatermarkLocation.TopLeft,
true,
100,
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,scale=384:-1,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
"0:1",
"[v]",
true)]
[TestCase(
false,
false,
false,
WatermarkLocation.TopLeft,
false,
90,
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,colorchannelmixer=aa=0.90,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
"0:1",
"[v]",
false)]
[TestCase(
false,
false,
false,
WatermarkLocation.TopLeft,
false,
90,
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,colorchannelmixer=aa=0.90,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
"0:1",
"[v]",
true)]
// TODO: do we need these anymore? interlaced content that isn't handled by mpeg2_cuvid?
// [TestCase(
// false,
// true,
// false,
// WatermarkLocation.TopLeft,
// false,
// 100,
// "[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
// "0:1",
// "[v]")]
// [TestCase(
// false,
// true,
// false,
// WatermarkLocation.TopLeft,
// true,
// 100,
// "[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
// "0:1",
// "[v]")]
// [TestCase(
// true,
// true,
// false,
// WatermarkLocation.TopLeft,
// false,
// 100,
// "[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
// "[a]",
// "[v]")]
[TestCase(
true,
false,
false,
WatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
"[a]",
"[v]",
false)]
[TestCase(
true,
false,
false,
WatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
"[a]",
"[v]",
true)]
public void Should_Return_NVENC_Watermark(
bool alignAudio,
bool deinterlace,
bool intermittent,
WatermarkLocation location,
bool scaled,
int opacity,
string expectedVideoFilter,
string expectedAudioLabel,
string expectedVideoLabel,
bool scaledSource)
{
var watermark = new ChannelWatermark
{
Mode = intermittent
? ChannelWatermarkMode.Intermittent
: ChannelWatermarkMode.Permanent,
DurationSeconds = intermittent ? 15 : 0,
FrequencyMinutes = intermittent ? 10 : 0,
Location = location,
Size = scaled ? WatermarkSize.Scaled : WatermarkSize.ActualSize,
WidthPercent = scaled ? 20 : 0,
Opacity = opacity,
HorizontalMarginPercent = 7,
VerticalMarginPercent = 5
};
Option<List<FadePoint>> maybeFadePoints = watermark.Mode == ChannelWatermarkMode.Intermittent
? Some(
WatermarkCalculator.CalculateFadePoints(
new DateTimeOffset(2022, 01, 31, 12, 25, 0, TimeSpan.FromHours(-5)),
TimeSpan.Zero,
TimeSpan.FromMinutes(55),
TimeSpan.Zero,
watermark.FrequencyMinutes,
watermark.DurationSeconds))
: None;
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
.WithWatermark(
Some(watermark),
maybeFadePoints,
new Resolution { Width = 1920, Height = 1080 },
None)
.WithDeinterlace(deinterlace)
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
if (scaledSource)
{
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be(expectedAudioLabel);
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
[TestCase(
true,
true,
false,
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:0]deinterlace_qsv,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
true,
true,
true,
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
false,
true,
false,
"[0:0]scale_qsv=w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
false,
false,
true,
"[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
false,
true,
true,
"[0:0]scale_qsv=w=1920:h=1000,setsar=1,hwdownload,format=nv12,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<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase(true, false, false, "[0:0]yadif_cuda[v]", "[v]")]
[TestCase(
true,
true,
false,
"[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:0]yadif_cuda,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
true,
true,
true,
"[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
true,
false,
"[0:0]scale_cuda=1920:1000,setsar=1[v]",
"[v]")]
[TestCase(
false,
false,
true,
"[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
true,
true,
"[0:0]scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,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)
.WithInputPixelFormat("h264");
if (scale)
{
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 });
}
if (pad)
{
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase("h264", true, false, false, "[0:0]deinterlace_vaapi[v]", "[v]")]
[TestCase(
"h264",
true,
true,
false,
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
"h264",
true,
false,
true,
"[0:0]deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"h264",
true,
true,
true,
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"h264",
false,
true,
false,
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
"h264",
false,
false,
true,
"[0:0]setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"h264",
false,
true,
true,
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
[TestCase(
"mpeg4",
true,
true,
false,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
"mpeg4",
true,
false,
true,
"[0:0]hwupload,deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
true,
true,
true,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
false,
true,
false,
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
"mpeg4",
false,
false,
true,
"[0:0]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
false,
true,
true,
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
public void Should_Return_VAAPI_Video_Filter(
string codec,
bool deinterlace,
bool scale,
bool pad,
string expectedVideoFilter,
string expectedVideoLabel)
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Vaapi)
.WithInputCodec(codec)
.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<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
}
}

337
ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs

@ -1,5 +1,4 @@
using System.Globalization; using System.Runtime.InteropServices;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
@ -9,29 +8,16 @@ namespace ErsatzTV.Core.FFmpeg;
public class FFmpegComplexFilterBuilder public class FFmpegComplexFilterBuilder
{ {
private Option<TimeSpan> _audioDuration = None;
private bool _boxBlur; private bool _boxBlur;
private bool _deinterlace;
private Option<HardwareAccelerationKind> _hardwareAccelerationKind = None;
private string _inputCodec;
private Option<List<FadePoint>> _maybeFadePoints = None; private Option<List<FadePoint>> _maybeFadePoints = None;
private bool _normalizeLoudness;
private Option<IDisplaySize> _padToSize = None; private Option<IDisplaySize> _padToSize = None;
private string _pixelFormat; private string _pixelFormat;
private IDisplaySize _resolution; private IDisplaySize _resolution;
private Option<IDisplaySize> _scaleToSize = None; private Option<IDisplaySize> _scaleToSize = None;
private Option<string> _subtitle; private Option<string> _subtitle;
private string _videoDecoder;
private FFmpegProfileVideoFormat _videoFormat;
private Option<ChannelWatermark> _watermark; private Option<ChannelWatermark> _watermark;
private Option<int> _watermarkIndex; private Option<int> _watermarkIndex;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
_hardwareAccelerationKind = Some(hardwareAccelerationKind);
return this;
}
public FFmpegComplexFilterBuilder WithScaling(IDisplaySize scaleToSize) public FFmpegComplexFilterBuilder WithScaling(IDisplaySize scaleToSize)
{ {
_scaleToSize = Some(scaleToSize); _scaleToSize = Some(scaleToSize);
@ -44,40 +30,6 @@ public class FFmpegComplexFilterBuilder
return this; return this;
} }
public FFmpegComplexFilterBuilder WithDeinterlace(bool deinterlace)
{
_deinterlace = deinterlace;
return this;
}
public FFmpegComplexFilterBuilder WithAlignedAudio(Option<TimeSpan> audioDuration)
{
_audioDuration = audioDuration;
return this;
}
public FFmpegComplexFilterBuilder WithNormalizeLoudness(bool normalizeLoudness)
{
_normalizeLoudness = normalizeLoudness;
return this;
}
public FFmpegComplexFilterBuilder WithInputCodec(Option<string> maybeCodec)
{
foreach (string codec in maybeCodec)
{
_inputCodec = codec;
}
return this;
}
public FFmpegComplexFilterBuilder WithDecoder(string decoder)
{
_videoDecoder = decoder;
return this;
}
public FFmpegComplexFilterBuilder WithInputPixelFormat(Option<string> maybePixelFormat) public FFmpegComplexFilterBuilder WithInputPixelFormat(Option<string> maybePixelFormat)
{ {
foreach (string pixelFormat in maybePixelFormat) foreach (string pixelFormat in maybePixelFormat)
@ -131,12 +83,6 @@ public class FFmpegComplexFilterBuilder
return this; return this;
} }
public FFmpegComplexFilterBuilder WithVideoFormat(FFmpegProfileVideoFormat videoFormat)
{
_videoFormat = videoFormat;
return this;
}
public Option<FFmpegComplexFilter> Build( public Option<FFmpegComplexFilter> Build(
bool videoOnly, bool videoOnly,
int videoInput, int videoInput,
@ -153,121 +99,13 @@ public class FFmpegComplexFilterBuilder
string videoLabel = $"{videoInput}:{(isSong ? "v" : videoStreamIndex.ToString())}"; string videoLabel = $"{videoInput}:{(isSong ? "v" : videoStreamIndex.ToString())}";
string audioLabel = audioStreamIndex.Match(index => $"{audioInput}:{index}", () => "0:a"); string audioLabel = audioStreamIndex.Match(index => $"{audioInput}:{index}", () => "0:a");
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch
{
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4" &&
(_deinterlace == false || !_pixelFormat.Contains("p10le")),
// we need an initial hwupload_cuda when only padding with these pixel formats
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _padToSize.IsSome =>
!isSong && !_pixelFormat.Contains("p10le") && !_pixelFormat.Contains("444"),
HardwareAccelerationKind.Nvenc => !isSong &&
(string.IsNullOrWhiteSpace(_videoDecoder) ||
_videoDecoder.Contains("cuvid")),
HardwareAccelerationKind.Qsv => !isSong,
HardwareAccelerationKind.VideoToolbox => false,
HardwareAccelerationKind.Amf => false,
_ => false
};
bool nvencDeinterlace = acceleration == HardwareAccelerationKind.Nvenc && _videoDecoder == "mpeg2_cuvid" &&
_deinterlace;
// mpeg2_cuvid will handle deinterlace and is "not" a hardware decode
if (nvencDeinterlace)
{
_deinterlace = false;
isHardwareDecode = false;
}
var audioFilterQueue = new List<string>();
var videoFilterQueue = new List<string>(); var videoFilterQueue = new List<string>();
var watermarkPreprocess = new List<string>(); var watermarkPreprocess = new List<string>();
string watermarkOverlay = string.Empty; string watermarkOverlay = string.Empty;
if (_normalizeLoudness)
{
audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11");
}
_audioDuration.IfSome(
audioDuration =>
{
var durationString = audioDuration.TotalMilliseconds.ToString(NumberFormatInfo.InvariantInfo);
audioFilterQueue.Add($"apad=whole_dur={durationString}ms");
});
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None &&
acceleration != HardwareAccelerationKind.VideoToolbox &&
acceleration != HardwareAccelerationKind.Amf &&
!isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome);
if (isSong) if (isSong)
{ {
switch (acceleration) videoFilterQueue.Add("format=yuv420p");
{
case HardwareAccelerationKind.Qsv:
videoFilterQueue.Add("format=nv12");
break;
case HardwareAccelerationKind.Vaapi:
videoFilterQueue.Add("format=nv12|vaapi");
break;
default:
videoFilterQueue.Add("format=yuv420p");
break;
}
}
switch (usesHardwareFilters || isSong, acceleration)
{
case (true, HardwareAccelerationKind.Nvenc):
videoFilterQueue.Add("hwupload_cuda");
break;
case (true, HardwareAccelerationKind.Qsv):
videoFilterQueue.Add("hwupload=extra_hw_frames=64");
break;
case (true, HardwareAccelerationKind.Vaapi):
videoFilterQueue.Add("hwupload");
break;
case (true, _) when usesHardwareFilters:
videoFilterQueue.Add("hwupload");
break;
}
if (_deinterlace)
{
Option<string> maybeFilter = acceleration switch
{
HardwareAccelerationKind.Qsv => "deinterlace_qsv",
HardwareAccelerationKind.Nvenc when !usesHardwareFilters && _pixelFormat.Contains("p10le") =>
"hwupload_cuda,yadif_cuda",
HardwareAccelerationKind.Nvenc => "yadif_cuda",
HardwareAccelerationKind.Vaapi => "deinterlace_vaapi",
_ => "yadif=1"
};
foreach (string filter in maybeFilter)
{
videoFilterQueue.Add(filter);
}
}
string[] h264hevc = { "h264", "hevc" };
if (_deinterlace == false && acceleration == HardwareAccelerationKind.Vaapi &&
(_pixelFormat ?? string.Empty).EndsWith("p10le") &&
h264hevc.Contains(_inputCodec) && (_pixelFormat != "yuv420p10le" || _inputCodec != "hevc"))
{
videoFilterQueue.Add("format=p010le,format=nv12|vaapi,hwupload");
}
if (acceleration == HardwareAccelerationKind.Vaapi && _pixelFormat == "yuv444p" &&
h264hevc.Contains(_inputCodec))
{
videoFilterQueue.Add("format=nv12|vaapi,hwupload");
} }
bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome; bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome;
@ -277,31 +115,6 @@ public class FFmpegComplexFilterBuilder
var softwareFilterQueue = new List<string>(); var softwareFilterQueue = new List<string>();
if (usesSoftwareFilters) if (usesSoftwareFilters)
{ {
if (acceleration != HardwareAccelerationKind.None && (isHardwareDecode || usesHardwareFilters))
{
Option<string> maybeFormat = acceleration switch
{
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
HardwareAccelerationKind.Nvenc when _padToSize.IsNone || nvencDeinterlace => None,
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
"format=p010le,format=nv12",
HardwareAccelerationKind.Qsv when isSong => "format=nv12,format=yuv420p",
_ when isSong => "format=yuv420p",
_ => "format=nv12"
};
foreach (string format in maybeFormat)
{
softwareFilterQueue.Add("hwdownload");
softwareFilterQueue.Add(format);
}
if (nvencDeinterlace)
{
softwareFilterQueue.Add("hwdownload");
}
}
if (_boxBlur) if (_boxBlur)
{ {
softwareFilterQueue.Add("boxblur=40"); softwareFilterQueue.Add("boxblur=40");
@ -314,16 +127,9 @@ public class FFmpegComplexFilterBuilder
foreach (ChannelWatermark watermark in _watermark) foreach (ChannelWatermark watermark in _watermark)
{ {
Option<string> maybeFormats = acceleration switch Option<string> maybeFormats = watermark.Opacity != 100 || hasFadePoints
{ ? "yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8"
// overlay_cuda only supports alpha with yuva420p : None;
HardwareAccelerationKind.Nvenc => "yuva420p",
_ when watermark.Opacity != 100 || hasFadePoints =>
"yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8",
_ => None
};
foreach (string formats in maybeFormats) foreach (string formats in maybeFormats)
{ {
@ -362,69 +168,29 @@ public class FFmpegComplexFilterBuilder
watermarkPreprocess.AddRange(fadePoints.Map(fp => fp.ToFilter())); watermarkPreprocess.AddRange(fadePoints.Map(fp => fp.ToFilter()));
} }
if (acceleration == HardwareAccelerationKind.Nvenc) watermarkOverlay = $"overlay={position}";
{
watermarkPreprocess.Add("hwupload_cuda");
}
watermarkOverlay = acceleration switch
{
HardwareAccelerationKind.Nvenc => $"overlay_cuda={position}",
_ => $"overlay={position}"
};
if (hasFadePoints && acceleration != HardwareAccelerationKind.Nvenc) if (hasFadePoints)
{ {
watermarkOverlay += "," + acceleration switch watermarkOverlay += "," + isSong switch
{ {
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi", true => "format=yuv420p",
_ when isSong => "format=yuv420p", false => "format=nv12"
_ => "format=nv12"
}; };
} }
} }
} }
string outputPixelFormat = null; string outputPixelFormat = null;
if (!usesSoftwareFilters && string.IsNullOrWhiteSpace(watermarkOverlay))
{
switch (acceleration, _videoFormat, _pixelFormat)
{
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le"):
outputPixelFormat = "yuv420p";
break;
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv444p10le"):
outputPixelFormat = "yuv444p";
break;
}
}
string outputFormat = (acceleration, _videoFormat, _pixelFormat) switch
{
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
_ => null
};
_scaleToSize.IfSome( _scaleToSize.IfSome(
size => size =>
{ {
string filter = acceleration switch string filter = videoOnly switch
{ {
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}", true =>
HardwareAccelerationKind.Nvenc when _watermark.IsSome && _scaleToSize.IsNone =>
$"format=yuv420p,hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc when _watermark.IsSome && _padToSize.IsNone =>
$"scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc when _watermark.IsNone && !string.IsNullOrEmpty(outputFormat) =>
$"scale_cuda={size.Width}:{size.Height}:format={outputFormat}",
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" && usesHardwareFilters == false =>
$"hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc => $"scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
_ when videoOnly =>
$"scale={size.Width}:{size.Height}:force_original_aspect_ratio=increase,crop={size.Width}:{size.Height}", $"scale={size.Width}:{size.Height}:force_original_aspect_ratio=increase,crop={size.Width}:{size.Height}",
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear" false => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
}; };
if (!string.IsNullOrWhiteSpace(filter)) if (!string.IsNullOrWhiteSpace(filter))
@ -435,14 +201,6 @@ public class FFmpegComplexFilterBuilder
if (scaleOrPad && _boxBlur == false) if (scaleOrPad && _boxBlur == false)
{ {
if (acceleration == HardwareAccelerationKind.Nvenc)
{
if (!isHardwareDecode && !string.IsNullOrWhiteSpace(outputPixelFormat))
{
videoFilterQueue.Add($"hwdownload,format={outputPixelFormat}");
}
}
videoFilterQueue.Add("setsar=1"); videoFilterQueue.Add("setsar=1");
} }
@ -450,63 +208,13 @@ public class FFmpegComplexFilterBuilder
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2")); _padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
if (acceleration == HardwareAccelerationKind.Nvenc && _watermark.IsSome)
{
if (_scaleToSize.IsSome)
{
videoFilterQueue.Add("hwdownload,format=nv12,format=yuv420p");
videoFilterQueue.Add("hwupload_cuda");
}
else if (_padToSize.IsNone)
{
videoFilterQueue.Add("scale_cuda=format=yuv420p");
}
else
{
videoFilterQueue.Add("format=yuv420p");
videoFilterQueue.Add("hwupload_cuda");
}
}
foreach (string subtitle in _subtitle) foreach (string subtitle in _subtitle)
{ {
videoFilterQueue.Add(subtitle); videoFilterQueue.Add(subtitle);
} }
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None &&
string.IsNullOrWhiteSpace(watermarkOverlay))
{
string upload = acceleration switch
{
HardwareAccelerationKind.Qsv => "hwupload=extra_hw_frames=64",
_ => "hwupload"
};
videoFilterQueue.Add(upload);
}
bool hasAudioFilters = audioFilterQueue.Any();
if (hasAudioFilters)
{
complexFilter.Append($"[{audioLabel}]");
complexFilter.Append(string.Join(",", audioFilterQueue));
audioLabel = "[a]";
complexFilter.Append(audioLabel);
}
// vaapi downsample 10bit hevc to 8bit h264
if (acceleration == HardwareAccelerationKind.Vaapi && !videoFilterQueue.Any() &&
_pixelFormat == "yuv420p10le" && _videoFormat == FFmpegProfileVideoFormat.H264)
{
videoFilterQueue.Add("scale_vaapi=format=nv12");
}
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay)) if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
{ {
if (hasAudioFilters)
{
complexFilter.Append(';');
}
if (videoFilterQueue.Any()) if (videoFilterQueue.Any())
{ {
complexFilter.Append($"[{videoLabel}]"); complexFilter.Append($"[{videoLabel}]");
@ -538,25 +246,6 @@ public class FFmpegComplexFilterBuilder
videoFilterQueue.Any() videoFilterQueue.Any()
? $"[vt]{watermarkLabel}{watermarkOverlay}" ? $"[vt]{watermarkLabel}{watermarkOverlay}"
: $"[{videoLabel}]{watermarkLabel}{watermarkOverlay}"); : $"[{videoLabel}]{watermarkLabel}{watermarkOverlay}");
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
{
switch (isSong, acceleration)
{
case (true, HardwareAccelerationKind.Nvenc):
complexFilter.Append(",hwupload_cuda");
break;
// no need to upload since we're already in the GPU with overlay_cuda
case (_, HardwareAccelerationKind.Nvenc) when scaleOrPad == false && _watermark.IsSome:
break;
case (_, HardwareAccelerationKind.Qsv):
complexFilter.Append(",format=yuv420p,hwupload=extra_hw_frames=64");
break;
default:
complexFilter.Append(",hwupload");
break;
}
}
} }
videoLabel = "[v]"; videoLabel = "[v]";

33
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -442,8 +442,37 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline); return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
} }
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) => public async Task<Command> WrapSegmenter(
_ffmpegProcessService.WrapSegmenter(ffmpegPath, saveReports, channel, scheme, host); string ffmpegPath,
bool saveReports,
Channel channel,
string scheme,
string host)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
var concatInputFile = new ConcatInputFile(
$"http://localhost:{Settings.ListenPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter",
resolution);
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
HardwareAccelerationMode.None,
None,
None,
None,
None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
FFmpegPipeline pipeline = pipelineBuilder.WrapSegmenter(
concatInputFile,
FFmpegState.Concat(saveReports, channel.Name));
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
}
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height) public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
{ {

535
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -19,12 +19,9 @@
// 3. This notice may not be removed or altered from any source distribution. // 3. This notice may not be removed or altered from any source distribution.
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
@ -32,36 +29,11 @@ internal class FFmpegProcessBuilder
{ {
private readonly List<string> _arguments = new(); private readonly List<string> _arguments = new();
private readonly string _ffmpegPath; private readonly string _ffmpegPath;
private readonly ILogger _logger;
private readonly bool _saveReports;
private FFmpegComplexFilterBuilder _complexFilterBuilder = new(); private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
private HardwareAccelerationKind _hwAccel;
private bool _isConcat;
private bool _noAutoScale;
private Option<int> _outputFramerate;
private string _outputPixelFormat;
private string _vaapiDevice;
private VaapiDriver _vaapiDriver;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
{
_ffmpegPath = ffmpegPath;
_saveReports = saveReports;
_logger = logger;
}
public FFmpegProcessBuilder WithVaapiDriver(VaapiDriver vaapiDriver, string vaapiDevice) public FFmpegProcessBuilder(string ffmpegPath)
{ {
if (vaapiDriver != VaapiDriver.Default) _ffmpegPath = ffmpegPath;
{
_vaapiDriver = vaapiDriver;
}
_vaapiDevice = string.IsNullOrWhiteSpace(vaapiDevice)
? "/dev/dri/renderD128"
: vaapiDevice;
return this;
} }
public FFmpegProcessBuilder WithThreads(int threads) public FFmpegProcessBuilder WithThreads(int threads)
@ -71,162 +43,6 @@ internal class FFmpegProcessBuilder
return this; return this;
} }
public FFmpegProcessBuilder WithHardwareAcceleration(
HardwareAccelerationKind hwAccel,
Option<string> pixelFormat,
FFmpegProfileVideoFormat videoFormat)
{
_hwAccel = 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:
string outputFormat = (videoFormat, pixelFormat.IfNone("")) switch
{
(FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
(FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
// ("hevc_nvenc", "yuv444p10le") => "p016le",
_ => "cuda"
};
_arguments.Add("-hwaccel");
_arguments.Add("cuda");
_arguments.Add("-hwaccel_output_format");
_arguments.Add(outputFormat);
break;
case HardwareAccelerationKind.Vaapi:
_arguments.Add("-hwaccel");
_arguments.Add("vaapi");
_arguments.Add("-vaapi_device");
_arguments.Add(_vaapiDevice);
_arguments.Add("-hwaccel_output_format");
_arguments.Add("vaapi");
break;
case HardwareAccelerationKind.VideoToolbox:
_arguments.Add("-hwaccel");
_arguments.Add("videotoolbox");
break;
}
_complexFilterBuilder = _complexFilterBuilder.WithHardwareAcceleration(hwAccel);
return this;
}
public FFmpegProcessBuilder WithRealtimeOutput(bool realtimeOutput)
{
if (realtimeOutput)
{
if (!_arguments.Contains("-re"))
{
_arguments.Add("-re");
}
}
else
{
_arguments.RemoveAll(s => s == "-re");
}
return this;
}
public FFmpegProcessBuilder WithSeek(Option<TimeSpan> maybeStart)
{
maybeStart.IfSome(
start =>
{
_arguments.Add("-ss");
_arguments.Add($"{start:c}");
});
return this;
}
public FFmpegProcessBuilder WithInfiniteLoop(bool loop = true)
{
if (loop)
{
_arguments.Add("-stream_loop");
_arguments.Add("-1");
if (_hwAccel is HardwareAccelerationKind.Qsv or HardwareAccelerationKind.Vaapi)
{
_noAutoScale = true;
}
}
return this;
}
public FFmpegProcessBuilder WithLoopedImage(string input)
{
_arguments.Add("-loop");
_arguments.Add("1");
return WithInput(input);
}
public FFmpegProcessBuilder WithPipe()
{
_arguments.Add("pipe:1");
return this;
}
public FFmpegProcessBuilder WithPixfmt(string pixfmt)
{
_arguments.Add("-pix_fmt");
_arguments.Add(pixfmt);
return this;
}
public FFmpegProcessBuilder WithLibavfilter()
{
_arguments.Add("-f");
_arguments.Add("lavfi");
return this;
}
public FFmpegProcessBuilder WithInput(string input)
{
_arguments.Add("-i");
_arguments.Add(input);
return this;
}
public FFmpegProcessBuilder WithMap(string map)
{
_arguments.Add("-map");
_arguments.Add(map);
return this;
}
public FFmpegProcessBuilder WithCopyCodec()
{
_arguments.Add("-c");
_arguments.Add("copy");
return this;
}
public FFmpegProcessBuilder WithFrameRate(Option<int> frameRate)
{
foreach (int fr in frameRate)
{
_arguments.Add("-r");
_arguments.Add($"{fr}");
_arguments.Add("-vsync");
_arguments.Add("cfr");
}
return this;
}
public FFmpegProcessBuilder WithWatermark( public FFmpegProcessBuilder WithWatermark(
Option<WatermarkOptions> watermarkOptions, Option<WatermarkOptions> watermarkOptions,
Option<List<FadePoint>> maybeFadePoints, Option<List<FadePoint>> maybeFadePoints,
@ -279,74 +95,12 @@ internal class FFmpegProcessBuilder
return this; return this;
} }
public FFmpegProcessBuilder WithInputCodec(
Option<TimeSpan> maybeStart,
bool loop,
string videoPath,
string audioPath,
string decoder,
Option<string> codec,
Option<string> pixelFormat,
bool deinterlace)
{
if (audioPath == videoPath)
{
WithSeek(maybeStart);
WithInfiniteLoop(loop);
}
else
{
_noAutoScale = true;
_outputFramerate = 30;
_arguments.Add("-loop");
_arguments.Add("1");
}
if (!string.IsNullOrWhiteSpace(decoder))
{
_arguments.Add("-c:v");
_arguments.Add(decoder);
if (decoder == "mpeg2_cuvid" && deinterlace)
{
_arguments.Add("-deint");
_arguments.Add("2");
}
_complexFilterBuilder = _complexFilterBuilder
.WithDecoder(decoder);
}
_complexFilterBuilder = _complexFilterBuilder
.WithInputCodec(codec)
.WithInputPixelFormat(pixelFormat);
_arguments.Add("-i");
_arguments.Add(videoPath);
if (audioPath != videoPath)
{
WithSeek(maybeStart);
_arguments.Add("-i");
_arguments.Add(audioPath);
}
return this;
}
public FFmpegProcessBuilder WithSongInput( public FFmpegProcessBuilder WithSongInput(
string videoPath, string videoPath,
Option<string> codec,
Option<string> pixelFormat, Option<string> pixelFormat,
bool boxBlur) bool boxBlur)
{ {
_noAutoScale = true;
_outputFramerate = 30;
_complexFilterBuilder = _complexFilterBuilder _complexFilterBuilder = _complexFilterBuilder
.WithInputCodec(codec)
.WithInputPixelFormat(pixelFormat) .WithInputPixelFormat(pixelFormat)
.WithBoxBlur(boxBlur); .WithBoxBlur(boxBlur);
@ -356,52 +110,6 @@ internal class FFmpegProcessBuilder
return this; return this;
} }
public FFmpegProcessBuilder WithConcat(string concatPlaylist)
{
_isConcat = true;
var arguments = new List<string>
{
"-f", "concat",
"-safe", "0",
"-protocol_whitelist", "file,http,tcp,https,tcp,tls",
"-probesize", "32",
"-i", concatPlaylist,
"-c", "copy",
"-muxdelay", "0",
"-muxpreload", "0"
// "-avoid_negative_ts", "make_zero"
};
_arguments.AddRange(arguments);
return this;
}
public FFmpegProcessBuilder WithMetadata(Channel channel, Option<MediaStream> maybeAudioStream)
{
if (channel.StreamingMode == StreamingMode.TransportStream)
{
_arguments.AddRange(new[] { "-map_metadata", "-1" });
}
foreach (MediaStream audioStream in maybeAudioStream)
{
if (!string.IsNullOrWhiteSpace(audioStream.Language))
{
_arguments.AddRange(new[] { "-metadata:s:a:0", $"language={audioStream.Language}" });
}
}
var arguments = new List<string>
{
"-metadata", "service_provider=\"ErsatzTV\"",
"-metadata", $"service_name=\"{channel.Name}\""
};
_arguments.AddRange(arguments);
return this;
}
public FFmpegProcessBuilder WithFormatFlags(IEnumerable<string> formatFlags) public FFmpegProcessBuilder WithFormatFlags(IEnumerable<string> formatFlags)
{ {
_arguments.Add("-fflags"); _arguments.Add("-fflags");
@ -409,138 +117,6 @@ internal class FFmpegProcessBuilder
return this; return this;
} }
public FFmpegProcessBuilder WithDuration(TimeSpan duration)
{
_arguments.Add("-t");
_arguments.Add($"{duration:c}");
return this;
}
public FFmpegProcessBuilder WithFormat(string format)
{
_arguments.Add("-f");
_arguments.Add($"{format}");
return this;
}
public FFmpegProcessBuilder WithInitialDiscontinuity()
{
_arguments.Add("-mpegts_flags");
_arguments.Add("+initial_discontinuity");
return this;
}
public FFmpegProcessBuilder WithHls(
string channelNumber,
Option<MediaVersion> mediaVersion,
long ptsOffset,
Option<int> maybeTimeScale,
Option<int> maybeFrameRate)
{
const int SEGMENT_SECONDS = 4;
int frameRate = maybeFrameRate.IfNone(GetFrameRateFromMediaVersion(mediaVersion));
foreach (int timescale in maybeTimeScale)
{
_arguments.Add("-output_ts_offset");
_arguments.Add($"{(ptsOffset / (double)timescale).ToString(NumberFormatInfo.InvariantInfo)}");
}
_arguments.AddRange(
new[]
{
"-g", $"{frameRate * SEGMENT_SECONDS}",
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
"-f", "hls",
"-hls_time", $"{SEGMENT_SECONDS}",
"-hls_list_size", "0",
"-segment_list_flags", "+live",
"-hls_segment_filename",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live%06d.ts"),
"-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments",
"-mpegts_flags", "+initial_discontinuity",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
});
return this;
}
public FFmpegProcessBuilder WithPlaybackArgs(
FFmpegPlaybackSettings playbackSettings,
string videoCodec,
string audioCodec)
{
var arguments = new List<string>
{
"-c:v", videoCodec,
"-flags", "cgop",
// disable scene change detection except with mpeg2video
"-sc_threshold", playbackSettings.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video ? "1000000000" : "0"
};
if (!string.IsNullOrWhiteSpace(_outputPixelFormat))
{
arguments.AddRange(new[] { "-pix_fmt", _outputPixelFormat });
}
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
bitrate =>
new[]
{
"-b:v", $"{bitrate}k",
"-maxrate:v", $"{bitrate}k"
},
Array.Empty<string>());
arguments.AddRange(videoBitrateArgs);
playbackSettings.VideoBufferSize
.IfSome(bufferSize => arguments.AddRange(new[] { "-bufsize:v", $"{bufferSize}k" }));
string[] audioBitrateArgs = playbackSettings.AudioBitrate.Match(
bitrate =>
new[]
{
"-b:a", $"{bitrate}k",
"-maxrate:a", $"{bitrate}k"
},
Array.Empty<string>());
arguments.AddRange(audioBitrateArgs);
playbackSettings.AudioBufferSize
.IfSome(bufferSize => arguments.AddRange(new[] { "-bufsize:a", $"{bufferSize}k" }));
playbackSettings.AudioChannels
.IfSome(channels => arguments.AddRange(new[] { "-ac", $"{channels}" }));
playbackSettings.AudioSampleRate
.IfSome(sampleRate => arguments.AddRange(new[] { "-ar", $"{sampleRate}k" }));
arguments.AddRange(
new[]
{
"-c:a", audioCodec,
"-movflags", "+faststart",
"-muxdelay", "0",
"-muxpreload", "0"
});
_arguments.AddRange(arguments);
if (_noAutoScale)
{
_arguments.Add("-noautoscale");
}
foreach (int framerate in _outputFramerate)
{
_arguments.Add("-r");
_arguments.Add(framerate.ToString());
}
return this;
}
public FFmpegProcessBuilder WithScaling(IDisplaySize displaySize) public FFmpegProcessBuilder WithScaling(IDisplaySize displaySize)
{ {
_complexFilterBuilder = _complexFilterBuilder.WithScaling(displaySize); _complexFilterBuilder = _complexFilterBuilder.WithScaling(displaySize);
@ -553,35 +129,6 @@ internal class FFmpegProcessBuilder
return this; return this;
} }
public FFmpegProcessBuilder WithAlignedAudio(Option<TimeSpan> audioDuration)
{
_complexFilterBuilder = _complexFilterBuilder.WithAlignedAudio(audioDuration);
return this;
}
public FFmpegProcessBuilder WithNormalizeLoudness(bool normalizeLoudness)
{
_complexFilterBuilder = _complexFilterBuilder.WithNormalizeLoudness(normalizeLoudness);
return this;
}
public FFmpegProcessBuilder WithVideoTrackTimeScale(Option<int> videoTrackTimeScale)
{
videoTrackTimeScale.IfSome(
timeScale =>
{
_arguments.Add("-video_track_timescale");
_arguments.Add($"{timeScale}");
});
return this;
}
public FFmpegProcessBuilder WithDeinterlace(bool deinterlace)
{
_complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace);
return this;
}
public FFmpegProcessBuilder WithOutputFormat(string format, string output) public FFmpegProcessBuilder WithOutputFormat(string format, string output)
{ {
_arguments.Add("-f"); _arguments.Add("-f");
@ -597,11 +144,8 @@ internal class FFmpegProcessBuilder
MediaStream videoStream, MediaStream videoStream,
Option<MediaStream> maybeAudioStream, Option<MediaStream> maybeAudioStream,
string videoPath, string videoPath,
Option<string> audioPath, Option<string> audioPath)
FFmpegProfileVideoFormat videoFormat)
{ {
_complexFilterBuilder = _complexFilterBuilder.WithVideoFormat(videoFormat);
int videoStreamIndex = videoStream.Index; int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index); Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
@ -615,10 +159,6 @@ internal class FFmpegProcessBuilder
else if (audioPath.IfNone("NotARealPath") != videoPath) else if (audioPath.IfNone("NotARealPath") != videoPath)
{ {
audioIndex = 1; audioIndex = 1;
if (_hwAccel == HardwareAccelerationKind.None)
{
_outputPixelFormat = "yuv420p";
}
} }
string videoLabel = $"{videoIndex}:{videoStreamIndex}"; string videoLabel = $"{videoIndex}:{videoStreamIndex}";
@ -639,11 +179,6 @@ internal class FFmpegProcessBuilder
_arguments.Add(filter.ComplexFilter); _arguments.Add(filter.ComplexFilter);
videoLabel = filter.VideoLabel; videoLabel = filter.VideoLabel;
audioLabel = filter.AudioLabel; audioLabel = filter.AudioLabel;
if (!string.IsNullOrWhiteSpace(filter.PixelFormat))
{
_outputPixelFormat = filter.PixelFormat;
}
}); });
foreach (string _ in audioPath) foreach (string _ in audioPath)
@ -676,44 +211,6 @@ internal class FFmpegProcessBuilder
StandardOutputEncoding = Encoding.UTF8 StandardOutputEncoding = Encoding.UTF8
}; };
if (_hwAccel == HardwareAccelerationKind.Vaapi)
{
switch (_vaapiDriver)
{
case VaapiDriver.i965:
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "i965";
break;
case VaapiDriver.iHD:
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "iHD";
break;
case VaapiDriver.RadeonSI:
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "radeonsi";
break;
case VaapiDriver.Nouveau:
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "nouveau";
break;
}
}
if (_saveReports)
{
string fileName = _isConcat
? Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-concat.log")
: Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-transcode.log");
// rework filename in a format that works on windows
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// \ is escape, so use / for directory separators
fileName = fileName.Replace(@"\", @"/");
// colon after drive letter needs to be escaped
fileName = fileName.Replace(@":/", @"\:/");
}
startInfo.EnvironmentVariables["FFREPORT"] = $"file={fileName}:level=32";
}
startInfo.ArgumentList.Add("-nostdin"); startInfo.ArgumentList.Add("-nostdin");
foreach (string argument in _arguments) foreach (string argument in _arguments)
{ {
@ -725,30 +222,4 @@ internal class FFmpegProcessBuilder
StartInfo = startInfo StartInfo = startInfo
}; };
} }
private int GetFrameRateFromMediaVersion(Option<MediaVersion> mediaVersion)
{
var frameRate = 24;
foreach (MediaVersion version in mediaVersion)
{
if (!int.TryParse(version.RFrameRate, out int fr))
{
string[] split = (version.RFrameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
fr = (int)Math.Round(left / (double)right);
}
else
{
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
fr = 24;
}
}
frameRate = fr;
}
return frameRate;
}
} }

31
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -40,30 +40,6 @@ public class FFmpegProcessService
_logger = logger; _logger = logger;
} }
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
Process process = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(true)
.WithInput($"http://localhost:{Settings.ListenPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter")
.WithMap("0")
.WithCopyCodec()
.WithMetadata(channel, None)
.WithFormat("mpegts")
.WithPipe()
.Build();
return Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
}
public async Task<Either<BaseError, string>> GenerateSongImage( public async Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath, string ffmpegPath,
string ffprobePath, string ffprobePath,
@ -130,11 +106,11 @@ public class FFmpegProcessService
false, false,
Option<int>.None); Option<int>.None);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger) FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
.WithThreads(1) .WithThreads(1)
.WithQuiet() .WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags) .WithFormatFlags(playbackSettings.FormatFlags)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur) .WithSongInput(videoPath, videoStream.PixelFormat, boxBlur)
.WithWatermark(watermarkOptions, None, channel.FFmpegProfile.Resolution) .WithWatermark(watermarkOptions, None, channel.FFmpegProfile.Resolution)
.WithSubtitleFile(subtitleFile); .WithSubtitleFile(subtitleFile);
@ -153,8 +129,7 @@ public class FFmpegProcessService
videoStream, videoStream,
None, None,
videoPath, videoPath,
None, None)
playbackSettings.VideoFormat)
.WithOutputFormat("apng", outputFile) .WithOutputFormat("apng", outputFile)
.Build(); .Build();

2
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -53,7 +53,7 @@ public interface IFFmpegProcessService
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host); Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host); Task<Command> WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height); Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);

1
ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs

@ -11,5 +11,4 @@ public interface IFallbackMetadataProvider
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo); Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo); Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo);
Option<SongMetadata> GetFallbackMetadata(Song song); Option<SongMetadata> GetFallbackMetadata(Song song);
string GetSortTitle(string title);
} }

8
ErsatzTV.Core/Interfaces/Metadata/Nfo/IArtistNfoReader.cs

@ -1,8 +0,0 @@
using ErsatzTV.Core.Metadata.Nfo;
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IArtistNfoReader
{
Task<Either<BaseError, ArtistNfo>> ReadFromFile(string fileName);
}

8
ErsatzTV.Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs

@ -1,8 +0,0 @@
using ErsatzTV.Core.Metadata.Nfo;
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IEpisodeNfoReader
{
Task<Either<BaseError, List<TvShowEpisodeNfo>>> ReadFromFile(string fileName);
}

8
ErsatzTV.Core/Interfaces/Metadata/Nfo/IMovieNfoReader.cs

@ -1,8 +0,0 @@
using ErsatzTV.Core.Metadata.Nfo;
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IMovieNfoReader
{
Task<Either<BaseError, MovieNfo>> ReadFromFile(string fileName);
}

8
ErsatzTV.Core/Interfaces/Metadata/Nfo/IMusicVideoNfoReader.cs

@ -1,8 +0,0 @@
using ErsatzTV.Core.Metadata.Nfo;
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IMusicVideoNfoReader
{
Task<Either<BaseError, MusicVideoNfo>> ReadFromFile(string fileName);
}

8
ErsatzTV.Core/Interfaces/Metadata/Nfo/IOtherVideoNfoReader.cs

@ -1,8 +0,0 @@
using ErsatzTV.Core.Metadata.Nfo;
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IOtherVideoNfoReader
{
Task<Either<BaseError, OtherVideoNfo>> ReadFromFile(string fileName);
}

8
ErsatzTV.Core/Interfaces/Metadata/Nfo/ITvShowNfoReader.cs

@ -1,8 +0,0 @@
using ErsatzTV.Core.Metadata.Nfo;
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface ITvShowNfoReader
{
Task<Either<BaseError, TvShowNfo>> ReadFromFile(string fileName);
}

1
ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs

@ -13,7 +13,6 @@ public interface IMetadataRepository
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(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true); Task<bool> UpdateLocalStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true);
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
ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs

@ -18,5 +18,4 @@ public interface IMovieRepository
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata); Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
Task<bool> AddDirector(MovieMetadata metadata, Director director); Task<bool> AddDirector(MovieMetadata metadata, Director director);
Task<bool> AddWriter(MovieMetadata metadata, Writer writer); Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
} }

30
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -125,36 +125,6 @@ public class FallbackMetadataProvider : IFallbackMetadataProvider
return GetSongMetadata(path, metadata); return GetSongMetadata(path, metadata);
} }
public string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return title;
}
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(4);
}
if (title.StartsWith("a ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(2);
}
if (title.StartsWith("an ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(3);
}
if (title.StartsWith("Æ"))
{
return title.Replace("Æ", "E");
}
return title;
}
private List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata) private List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata)
{ {
var result = new List<EpisodeMetadata>(); var result = new List<EpisodeMetadata>();

2
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -94,7 +94,7 @@ public class LocalFileSystem : ILocalFileSystem
public IEnumerable<string> ListFiles(string folder, string searchPattern) public IEnumerable<string> ListFiles(string folder, string searchPattern)
{ {
if (Directory.Exists(folder)) if (folder is not null && Directory.Exists(folder))
{ {
try try
{ {

18
ErsatzTV.Core/Metadata/Nfo/ActorNfo.cs

@ -1,18 +0,0 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
public class ActorNfo
{
[XmlElement("name")]
public string Name { get; set; }
[XmlElement("role")]
public string Role { get; set; }
[XmlElement("order")]
public int? Order { get; set; }
[XmlElement("thumb")]
public string Thumb { get; set; }
}

25
ErsatzTV.Core/Metadata/Nfo/ArtistNfo.cs

@ -1,25 +0,0 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
[XmlRoot("artist")]
public class ArtistNfo
{
[XmlElement("name")]
public string Name { get; set; }
[XmlElement("disambiguation")]
public string Disambiguation { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("style")]
public List<string> Styles { get; set; }
[XmlElement("mood")]
public List<string> Moods { get; set; }
[XmlElement("biography")]
public string Biography { get; set; }
}

52
ErsatzTV.Core/Metadata/Nfo/MovieNfo.cs

@ -1,52 +0,0 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
[XmlRoot("movie")]
public class MovieNfo
{
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("sorttitle")]
public string SortTitle { get; set; }
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("mpaa")]
public string ContentRating { get; set; }
[XmlElement("premiered")]
public Option<DateTime> Premiered { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("tagline")]
public string Tagline { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("credits")]
public List<string> Writers { get; set; }
[XmlElement("director")]
public List<string> Directors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}

37
ErsatzTV.Core/Metadata/Nfo/MusicVideoNfo.cs

@ -1,37 +0,0 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
[XmlRoot("musicvideo")]
public class MusicVideoNfo
{
[XmlElement("artist")]
public List<string> Artists { get; set; }
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("album")]
public string Album { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("track")]
public int Track { get; set; }
[XmlElement("aired")]
public Option<DateTime> Aired { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
}

52
ErsatzTV.Core/Metadata/Nfo/OtherVideoNfo.cs

@ -1,52 +0,0 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
[XmlRoot("movie")]
public class OtherVideoNfo
{
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("sorttitle")]
public string SortTitle { get; set; }
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("mpaa")]
public string ContentRating { get; set; }
[XmlElement("premiered")]
public Option<DateTime> Premiered { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("tagline")]
public string Tagline { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("credits")]
public List<string> Writers { get; set; }
[XmlElement("director")]
public List<string> Directors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}

40
ErsatzTV.Core/Metadata/Nfo/TvShowEpisodeNfo.cs

@ -1,40 +0,0 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
[XmlRoot("episodedetails")]
public class TvShowEpisodeNfo
{
[XmlElement("showtitle")]
public string ShowTitle { get; set; }
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("episode")]
public int Episode { get; set; }
[XmlElement("season")]
public int Season { get; set; }
[XmlElement("mpaa")]
public string ContentRating { get; set; }
[XmlElement("aired")]
public Option<DateTime> Aired { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("credits")]
public List<string> Writers { get; set; }
[XmlElement("director")]
public List<string> Directors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}

50
ErsatzTV.Core/Metadata/Nfo/TvShowNfo.cs

@ -1,50 +0,0 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
[XmlRoot("tvshow")]
public class TvShowNfo
{
[XmlElement("title")]
public string Title { get; set; }
[XmlIgnore]
public int? Year { get; set; }
[XmlElement("year")]
public string YearAsText
{
get => Year.HasValue ? Year.ToString() : null;
set => Year = !string.IsNullOrWhiteSpace(value) ? int.Parse(value) : default(int?);
}
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("tagline")]
public string Tagline { get; set; }
[XmlElement("mpaa")]
public string ContentRating { get; set; }
[XmlElement("premiered")]
public Option<DateTime> Premiered { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}

15
ErsatzTV.Core/Metadata/Nfo/UniqueIdNfo.cs

@ -1,15 +0,0 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
public class UniqueIdNfo
{
[XmlAttribute("default")]
public bool Default { get; set; }
[XmlAttribute("type")]
public string Type { get; set; }
[XmlText]
public string Guid { get; set; }
}

34
ErsatzTV.Core/Metadata/SortTitle.cs

@ -0,0 +1,34 @@
namespace ErsatzTV.Core.Metadata;
public static class SortTitle
{
public static string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return title;
}
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
return title[4..];
}
if (title.StartsWith("a ", StringComparison.OrdinalIgnoreCase))
{
return title[2..];
}
if (title.StartsWith("an ", StringComparison.OrdinalIgnoreCase))
{
return title[3..];
}
if (title.StartsWith("Æ"))
{
return title.Replace("Æ", "E");
}
return title;
}
}

3
ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs

@ -5,7 +5,6 @@ namespace ErsatzTV.Core.Scheduling;
public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly Collection _collection;
private readonly IList<MediaItem> _sortedMediaItems; private readonly IList<MediaItem> _sortedMediaItems;
public CustomOrderCollectionEnumerator( public CustomOrderCollectionEnumerator(
@ -13,8 +12,6 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
IList<MediaItem> mediaItems, IList<MediaItem> mediaItems,
CollectionEnumeratorState state) CollectionEnumeratorState state)
{ {
_collection = collection;
// TODO: this will break if we allow shows and seasons // TODO: this will break if we allow shows and seasons
_sortedMediaItems = collection.CollectionItems _sortedMediaItems = collection.CollectionItems
.OrderBy(ci => ci.CustomIndex) .OrderBy(ci => ci.CustomIndex)

25
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -214,6 +214,31 @@ public class PipelineBuilderBaseTests
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -re -stream_loop -1 -i http://localhost:8080/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -re -stream_loop -1 -i http://localhost:8080/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
} }
[Test]
public void Wrap_Segmenter_Test()
{
var resolution = new FrameSize(1920, 1080);
var concatInputFile = new ConcatInputFile("http://localhost:8080/iptv/channel/1.m3u8?mode=segmenter", resolution);
var builder = new SoftwarePipelineBuilder(
HardwareAccelerationMode.None,
None,
None,
None,
None,
"",
"",
_logger);
FFmpegPipeline result = builder.WrapSegmenter(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
result.PipelineSteps.Should().HaveCountGreaterThan(0);
string command = PrintCommand(None, None, None, concatInputFile, result);
command.Should().Be(
"-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -i http://localhost:8080/iptv/channel/1.m3u8?mode=segmenter -map 0 -c copy -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts pipe:1");
}
[Test] [Test]
public void HlsDirect_Test() public void HlsDirect_Test()
{ {

1
ErsatzTV.FFmpeg/CommandGenerator.cs

@ -97,7 +97,6 @@ public static class CommandGenerator
foreach (IPipelineStep step in sortedSteps) foreach (IPipelineStep step in sortedSteps)
{ {
arguments.AddRange(step.OutputOptions); arguments.AddRange(step.OutputOptions);
} }

286
ErsatzTV.FFmpeg/Filter/ComplexFilter.cs

@ -1,50 +1,35 @@
using ErsatzTV.FFmpeg.Environment; using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format; using ErsatzTV.FFmpeg.Pipeline;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.FFmpeg.Filter; namespace ErsatzTV.FFmpeg.Filter;
public class ComplexFilter : IPipelineStep public class ComplexFilter : IPipelineStep
{ {
private readonly FrameState _currentState;
private readonly FFmpegState _ffmpegState;
private readonly string _fontsDir;
private readonly ILogger _logger;
private readonly Option<AudioInputFile> _maybeAudioInputFile; private readonly Option<AudioInputFile> _maybeAudioInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile; private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
private readonly Option<IPixelFormat> _desiredPixelFormat; private readonly PipelineContext _pipelineContext;
private readonly FilterChain _filterChain;
private readonly Option<VideoInputFile> _maybeVideoInputFile; private readonly Option<VideoInputFile> _maybeVideoInputFile;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile; private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly FrameSize _resolution;
private readonly List<string> _outputOptions; private readonly List<string> _outputOptions;
private readonly List<IPipelineStep> _pipelineSteps;
private readonly IList<string> _arguments; private readonly IList<string> _arguments;
public ComplexFilter( public ComplexFilter(
FrameState currentState,
FFmpegState ffmpegState,
Option<VideoInputFile> maybeVideoInputFile, Option<VideoInputFile> maybeVideoInputFile,
Option<AudioInputFile> maybeAudioInputFile, Option<AudioInputFile> maybeAudioInputFile,
Option<WatermarkInputFile> maybeWatermarkInputFile, Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile, Option<SubtitleInputFile> maybeSubtitleInputFile,
Option<IPixelFormat> desiredPixelFormat, PipelineContext pipelineContext,
FrameSize resolution, FilterChain filterChain)
string fontsDir,
ILogger logger)
{ {
_currentState = currentState;
_ffmpegState = ffmpegState;
_maybeVideoInputFile = maybeVideoInputFile; _maybeVideoInputFile = maybeVideoInputFile;
_maybeAudioInputFile = maybeAudioInputFile; _maybeAudioInputFile = maybeAudioInputFile;
_maybeWatermarkInputFile = maybeWatermarkInputFile; _maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile; _maybeSubtitleInputFile = maybeSubtitleInputFile;
_desiredPixelFormat = desiredPixelFormat; _pipelineContext = pipelineContext;
_resolution = resolution; _filterChain = filterChain;
_fontsDir = fontsDir;
_logger = logger;
_outputOptions = new List<string>(); _outputOptions = new List<string>();
_pipelineSteps = new List<IPipelineStep>();
_arguments = Arguments(); _arguments = Arguments();
} }
@ -54,18 +39,18 @@ public class ComplexFilter : IPipelineStep
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>(); public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> FilterOptions => _arguments; public IList<string> FilterOptions => _arguments;
public IList<string> OutputOptions => _outputOptions; public IList<string> OutputOptions => _outputOptions;
public IList<IPipelineStep> PipelineSteps => _pipelineSteps;
public FrameState NextState(FrameState currentState) => currentState; public FrameState NextState(FrameState currentState) => currentState;
// for testing
public FilterChain FilterChain => _filterChain;
private List<string> Arguments() private List<string> Arguments()
{ {
var state = _currentState;
var audioLabel = "0:a"; var audioLabel = "0:a";
var videoLabel = "0:v"; var videoLabel = "0:v";
string watermarkLabel; string? watermarkLabel = null;
string subtitleLabel; string? subtitleLabel = null;
var result = new List<string>(); var result = new List<string>();
@ -88,7 +73,10 @@ public class ComplexFilter : IPipelineStep
foreach ((string path, _) in _maybeAudioInputFile) foreach ((string path, _) in _maybeAudioInputFile)
{ {
if (!distinctPaths.Contains(path)) if (!distinctPaths.Contains(path) ||
// use audio as a separate input with vaapi/qsv
_pipelineContext.HardwareAccelerationMode is HardwareAccelerationMode.Vaapi
or HardwareAccelerationMode.Qsv)
{ {
distinctPaths.Add(path); distinctPaths.Add(path);
} }
@ -116,48 +104,30 @@ public class ComplexFilter : IPipelineStep
foreach ((int index, _, _) in videoInputFile.Streams) foreach ((int index, _, _) in videoInputFile.Streams)
{ {
videoLabel = $"{inputIndex}:{index}"; videoLabel = $"{inputIndex}:{index}";
if (videoInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter))) if (_filterChain.VideoFilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{ {
videoFilterComplex += $"[{inputIndex}:{index}]"; videoFilterComplex += $"[{inputIndex}:{index}]";
videoFilterComplex += string.Join( videoFilterComplex += string.Join(
",", ",",
videoInputFile.FilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s))); _filterChain.VideoFilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[v]"; videoLabel = "[v]";
videoFilterComplex += videoLabel; videoFilterComplex += videoLabel;
} }
} }
} }
foreach (AudioInputFile audioInputFile in _maybeAudioInputFile)
{
int inputIndex = distinctPaths.IndexOf(audioInputFile.Path);
foreach ((int index, _, _) in audioInputFile.Streams)
{
audioLabel = $"{inputIndex}:{index}";
if (audioInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
audioFilterComplex += $"[{inputIndex}:{index}]";
audioFilterComplex += string.Join(
",",
audioInputFile.FilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
audioLabel = "[a]";
audioFilterComplex += audioLabel;
}
}
}
foreach (WatermarkInputFile watermarkInputFile in _maybeWatermarkInputFile) foreach (WatermarkInputFile watermarkInputFile in _maybeWatermarkInputFile)
{ {
int inputIndex = distinctPaths.IndexOf(watermarkInputFile.Path); int inputIndex = distinctPaths.IndexOf(watermarkInputFile.Path);
foreach ((int index, _, _) in watermarkInputFile.Streams) foreach ((int index, _, _) in watermarkInputFile.Streams)
{ {
watermarkLabel = $"{inputIndex}:{index}"; watermarkLabel = $"{inputIndex}:{index}";
if (watermarkInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter))) if (_filterChain.WatermarkFilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{ {
watermarkFilterComplex += $"[{inputIndex}:{index}]"; watermarkFilterComplex += $"[{inputIndex}:{index}]";
watermarkFilterComplex += string.Join( watermarkFilterComplex += string.Join(
",", ",",
watermarkInputFile.FilterSteps.Select(f => f.Filter) _filterChain.WatermarkFilterSteps.Select(f => f.Filter)
.Filter(s => !string.IsNullOrWhiteSpace(s))); .Filter(s => !string.IsNullOrWhiteSpace(s)));
watermarkLabel = "[wm]"; watermarkLabel = "[wm]";
watermarkFilterComplex += watermarkLabel; watermarkFilterComplex += watermarkLabel;
@ -166,66 +136,6 @@ public class ComplexFilter : IPipelineStep
{ {
watermarkLabel = $"[{watermarkLabel}]"; watermarkLabel = $"[{watermarkLabel}]";
} }
foreach (VideoInputFile videoInputFile in _maybeVideoInputFile)
foreach (VideoStream stream in videoInputFile.VideoStreams)
{
IPipelineFilterStep overlayFilter = AvailableWatermarkOverlayFilters.ForAcceleration(
_ffmpegState.EncoderHardwareAccelerationMode,
watermarkInputFile.DesiredState,
_resolution,
stream.SquarePixelFrameSize(_resolution),
_logger);
if (overlayFilter.Filter != string.Empty)
{
_pipelineSteps.Add(overlayFilter);
state = overlayFilter.NextState(state);
string tempVideoLabel = string.IsNullOrWhiteSpace(videoFilterComplex)
? $"[{videoLabel}]"
: videoLabel;
// vaapi uses software overlay and needs to upload
// videotoolbox seems to require a hwupload for hevc
// also wait to upload if a subtitle overlay is coming
string uploadDownloadFilter = string.Empty;
if (_maybeSubtitleInputFile.IsNone &&
(_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Vaapi ||
_ffmpegState.EncoderHardwareAccelerationMode ==
HardwareAccelerationMode.VideoToolbox &&
state.VideoFormat == VideoFormat.Hevc))
{
var hardwareUpload = new HardwareUploadFilter(_ffmpegState);
_pipelineSteps.Add(hardwareUpload);
uploadDownloadFilter = hardwareUpload.Filter;
state = state with { FrameDataLocation = FrameDataLocation.Hardware };
}
if (_maybeSubtitleInputFile.Map(s => !s.IsImageBased).IfNone(false) &&
_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.Vaapi &&
_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.VideoToolbox &&
_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.Amf)
{
var hardwareDownload = new HardwareDownloadFilter(state);
_pipelineSteps.Add(hardwareDownload);
uploadDownloadFilter = hardwareDownload.Filter;
state = state with { FrameDataLocation = FrameDataLocation.Software };
}
if (!string.IsNullOrWhiteSpace(uploadDownloadFilter))
{
uploadDownloadFilter = "," + uploadDownloadFilter;
}
watermarkOverlayFilterComplex =
$"{tempVideoLabel}{watermarkLabel}{overlayFilter.Filter}{uploadDownloadFilter}[vf]";
// change the mapped label
videoLabel = "[vf]";
}
}
} }
} }
@ -235,12 +145,12 @@ public class ComplexFilter : IPipelineStep
foreach ((int index, _, _) in subtitleInputFile.Streams) foreach ((int index, _, _) in subtitleInputFile.Streams)
{ {
subtitleLabel = $"{inputIndex}:{index}"; subtitleLabel = $"{inputIndex}:{index}";
if (subtitleInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter))) if (_filterChain.SubtitleFilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{ {
subtitleFilterComplex += $"[{inputIndex}:{index}]"; subtitleFilterComplex += $"[{inputIndex}:{index}]";
subtitleFilterComplex += string.Join( subtitleFilterComplex += string.Join(
",", ",",
subtitleInputFile.FilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s))); _filterChain.SubtitleFilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
subtitleLabel = "[st]"; subtitleLabel = "[st]";
subtitleFilterComplex += subtitleLabel; subtitleFilterComplex += subtitleLabel;
} }
@ -248,116 +158,61 @@ public class ComplexFilter : IPipelineStep
{ {
subtitleLabel = $"[{subtitleLabel}]"; subtitleLabel = $"[{subtitleLabel}]";
} }
string filter;
if (subtitleInputFile.IsImageBased)
{
IPipelineFilterStep overlayFilter = AvailableSubtitleOverlayFilters.ForAcceleration(
_ffmpegState.EncoderHardwareAccelerationMode);
_pipelineSteps.Add(overlayFilter);
state = overlayFilter.NextState(state);
filter = overlayFilter.Filter;
}
else
{
subtitleLabel = string.Empty;
var subtitlesFilter = new SubtitlesFilter(_fontsDir, subtitleInputFile);
_pipelineSteps.Add(subtitlesFilter);
state = subtitlesFilter.NextState(state);
filter = subtitlesFilter.Filter;
}
if (filter != string.Empty)
{
string tempVideoLabel = videoLabel.StartsWith('[') && videoLabel.EndsWith(']')
? videoLabel
: $"[{videoLabel}]";
// vaapi uses software overlay and needs to upload
// videotoolbox seems to require a hwupload for hevc
string uploadFilter = string.Empty;
if (_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Vaapi
|| _ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
state.VideoFormat == VideoFormat.Hevc)
{
var hardwareUpload = new HardwareUploadFilter(_ffmpegState);
_pipelineSteps.Add(hardwareUpload);
uploadFilter = hardwareUpload.Filter;
if (!string.IsNullOrWhiteSpace(uploadFilter))
{
state = state with { FrameDataLocation = FrameDataLocation.Hardware };
}
}
if (!string.IsNullOrWhiteSpace(uploadFilter))
{
uploadFilter = "," + uploadFilter;
}
subtitleOverlayFilterComplex = $"{tempVideoLabel}{subtitleLabel}{filter}{uploadFilter}[vst]";
// change the mapped label
videoLabel = "[vst]";
}
} }
} }
foreach (VideoStream videoStream in _maybeVideoInputFile.Map(vif => vif.VideoStreams).Flatten()) // overlay subtitle
foreach (IPixelFormat pixelFormat in _desiredPixelFormat) if (!string.IsNullOrWhiteSpace(subtitleLabel) && _filterChain.SubtitleOverlayFilterSteps.Any())
{ {
_logger.LogDebug("Desired pixel format {PixelFormat}", pixelFormat); subtitleOverlayFilterComplex += $"{ProperLabel(videoLabel)}{ProperLabel(subtitleLabel)}";
subtitleOverlayFilterComplex += string.Join(
string tempVideoLabel = videoLabel.StartsWith("[") && videoLabel.EndsWith("]") ",",
? videoLabel _filterChain.SubtitleOverlayFilterSteps.Select(f => f.Filter)
: $"[{videoLabel}]"; .Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[vst]";
subtitleOverlayFilterComplex += videoLabel;
}
// normalize pixel format and color params // overlay watermark
string filter = string.Empty; if (!string.IsNullOrWhiteSpace(watermarkLabel) && _filterChain.WatermarkOverlayFilterSteps.Any())
{
watermarkOverlayFilterComplex += $"{ProperLabel(videoLabel)}{ProperLabel(watermarkLabel)}";
watermarkOverlayFilterComplex += string.Join(
",",
_filterChain.WatermarkOverlayFilterSteps.Select(f => f.Filter)
.Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[vwm]";
watermarkOverlayFilterComplex += videoLabel;
}
if (!videoStream.ColorParams.IsBt709) // pixel format
{ if (_filterChain.PixelFormatFilterSteps.Any())
_logger.LogDebug("Adding colorspace filter"); {
var colorspace = new ColorspaceFilter(_currentState, videoStream, pixelFormat); pixelFormatFilterComplex += $"{ProperLabel(videoLabel)}";
_pipelineSteps.Add(colorspace); pixelFormatFilterComplex += string.Join(
filter = colorspace.Filter; ",",
} _filterChain.PixelFormatFilterSteps.Select(f => f.Filter)
.Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[vpf]";
pixelFormatFilterComplex += videoLabel;
}
if (state.PixelFormat.Map(f => f.FFmpegName) != pixelFormat.FFmpegName) foreach (AudioInputFile audioInputFile in _maybeAudioInputFile)
{
int inputIndex = distinctPaths.LastIndexOf(audioInputFile.Path);
foreach ((int index, _, _) in audioInputFile.Streams)
{ {
_logger.LogDebug( audioLabel = $"{inputIndex}:{index}";
"Format {A} doesn't equal {B}", if (audioInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
state.PixelFormat.Map(f => f.FFmpegName),
pixelFormat.FFmpegName);
if (videoStream.ColorParams.IsHdr)
{
_logger.LogWarning("HDR tone mapping is not implemented; colors may appear incorrect");
continue;
}
if (state.FrameDataLocation == FrameDataLocation.Software)
{
_logger.LogDebug("Frame data location is SOFTWARE");
_outputOptions.AddRange(new[] { "-pix_fmt", pixelFormat.FFmpegName });
}
else
{ {
_logger.LogDebug("Frame data location is HARDWARE"); audioFilterComplex += $"[{inputIndex}:{index}]";
filter = _ffmpegState.EncoderHardwareAccelerationMode switch audioFilterComplex += string.Join(
{ ",",
HardwareAccelerationMode.Nvenc => $"{filter},scale_cuda=format={pixelFormat.FFmpegName}", audioInputFile.FilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
_ => filter audioLabel = "[a]";
}; audioFilterComplex += audioLabel;
} }
} }
if (!string.IsNullOrWhiteSpace(filter))
{
pixelFormatFilterComplex = $"{tempVideoLabel}{filter}[vpf]";
// change the mapped label
videoLabel = "[vpf]";
}
} }
var filterComplex = string.Join( var filterComplex = string.Join(
@ -366,13 +221,12 @@ public class ComplexFilter : IPipelineStep
{ {
audioFilterComplex, audioFilterComplex,
videoFilterComplex, videoFilterComplex,
watermarkFilterComplex,
subtitleFilterComplex, subtitleFilterComplex,
watermarkOverlayFilterComplex, watermarkFilterComplex,
subtitleOverlayFilterComplex, subtitleOverlayFilterComplex,
watermarkOverlayFilterComplex,
pixelFormatFilterComplex pixelFormatFilterComplex
}.Where( }.Where(s => !string.IsNullOrWhiteSpace(s)));
s => !string.IsNullOrWhiteSpace(s)));
if (!string.IsNullOrWhiteSpace(filterComplex)) if (!string.IsNullOrWhiteSpace(filterComplex))
{ {
@ -393,4 +247,6 @@ public class ComplexFilter : IPipelineStep
return result; return result;
} }
private string ProperLabel(string label) => label.StartsWith("[") ? label : $"[{label}]";
} }

252
ErsatzTV.FFmpeg/Filter/NewComplexFilter.cs

@ -1,252 +0,0 @@
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Pipeline;
namespace ErsatzTV.FFmpeg.Filter;
public class NewComplexFilter : IPipelineStep
{
private readonly Option<AudioInputFile> _maybeAudioInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
private readonly PipelineContext _pipelineContext;
private readonly FilterChain _filterChain;
private readonly Option<VideoInputFile> _maybeVideoInputFile;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly List<string> _outputOptions;
private readonly IList<string> _arguments;
public NewComplexFilter(
Option<VideoInputFile> maybeVideoInputFile,
Option<AudioInputFile> maybeAudioInputFile,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile,
PipelineContext pipelineContext,
FilterChain filterChain)
{
_maybeVideoInputFile = maybeVideoInputFile;
_maybeAudioInputFile = maybeAudioInputFile;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
_pipelineContext = pipelineContext;
_filterChain = filterChain;
_outputOptions = new List<string>();
_arguments = Arguments();
}
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> FilterOptions => _arguments;
public IList<string> OutputOptions => _outputOptions;
public FrameState NextState(FrameState currentState) => currentState;
// for testing
public FilterChain FilterChain => _filterChain;
private List<string> Arguments()
{
var audioLabel = "0:a";
var videoLabel = "0:v";
string? watermarkLabel = null;
string? subtitleLabel = null;
var result = new List<string>();
string audioFilterComplex = string.Empty;
string videoFilterComplex = string.Empty;
string watermarkFilterComplex = string.Empty;
string watermarkOverlayFilterComplex = string.Empty;
string subtitleFilterComplex = string.Empty;
string subtitleOverlayFilterComplex = string.Empty;
string pixelFormatFilterComplex = string.Empty;
var distinctPaths = new List<string>();
foreach ((string path, _) in _maybeVideoInputFile)
{
if (!distinctPaths.Contains(path))
{
distinctPaths.Add(path);
}
}
foreach ((string path, _) in _maybeAudioInputFile)
{
if (!distinctPaths.Contains(path) ||
// use audio as a separate input with vaapi/qsv
_pipelineContext.HardwareAccelerationMode is HardwareAccelerationMode.Vaapi
or HardwareAccelerationMode.Qsv)
{
distinctPaths.Add(path);
}
}
foreach ((string path, _) in _maybeWatermarkInputFile)
{
if (!distinctPaths.Contains(path))
{
distinctPaths.Add(path);
}
}
foreach ((string path, _) in _maybeSubtitleInputFile)
{
if (!distinctPaths.Contains(path))
{
distinctPaths.Add(path);
}
}
foreach (VideoInputFile videoInputFile in _maybeVideoInputFile)
{
int inputIndex = distinctPaths.IndexOf(videoInputFile.Path);
foreach ((int index, _, _) in videoInputFile.Streams)
{
videoLabel = $"{inputIndex}:{index}";
if (_filterChain.VideoFilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
videoFilterComplex += $"[{inputIndex}:{index}]";
videoFilterComplex += string.Join(
",",
_filterChain.VideoFilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[v]";
videoFilterComplex += videoLabel;
}
}
}
foreach (WatermarkInputFile watermarkInputFile in _maybeWatermarkInputFile)
{
int inputIndex = distinctPaths.IndexOf(watermarkInputFile.Path);
foreach ((int index, _, _) in watermarkInputFile.Streams)
{
watermarkLabel = $"{inputIndex}:{index}";
if (_filterChain.WatermarkFilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
watermarkFilterComplex += $"[{inputIndex}:{index}]";
watermarkFilterComplex += string.Join(
",",
_filterChain.WatermarkFilterSteps.Select(f => f.Filter)
.Filter(s => !string.IsNullOrWhiteSpace(s)));
watermarkLabel = "[wm]";
watermarkFilterComplex += watermarkLabel;
}
else
{
watermarkLabel = $"[{watermarkLabel}]";
}
}
}
foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(s => !s.Copy))
{
int inputIndex = distinctPaths.IndexOf(subtitleInputFile.Path);
foreach ((int index, _, _) in subtitleInputFile.Streams)
{
subtitleLabel = $"{inputIndex}:{index}";
if (_filterChain.SubtitleFilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
subtitleFilterComplex += $"[{inputIndex}:{index}]";
subtitleFilterComplex += string.Join(
",",
_filterChain.SubtitleFilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
subtitleLabel = "[st]";
subtitleFilterComplex += subtitleLabel;
}
else
{
subtitleLabel = $"[{subtitleLabel}]";
}
}
}
// overlay subtitle
if (!string.IsNullOrWhiteSpace(subtitleLabel) && _filterChain.SubtitleOverlayFilterSteps.Any())
{
subtitleOverlayFilterComplex += $"{ProperLabel(videoLabel)}{ProperLabel(subtitleLabel)}";
subtitleOverlayFilterComplex += string.Join(
",",
_filterChain.SubtitleOverlayFilterSteps.Select(f => f.Filter)
.Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[vst]";
subtitleOverlayFilterComplex += videoLabel;
}
// overlay watermark
if (!string.IsNullOrWhiteSpace(watermarkLabel) && _filterChain.WatermarkOverlayFilterSteps.Any())
{
watermarkOverlayFilterComplex += $"{ProperLabel(videoLabel)}{ProperLabel(watermarkLabel)}";
watermarkOverlayFilterComplex += string.Join(
",",
_filterChain.WatermarkOverlayFilterSteps.Select(f => f.Filter)
.Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[vwm]";
watermarkOverlayFilterComplex += videoLabel;
}
// pixel format
if (_filterChain.PixelFormatFilterSteps.Any())
{
pixelFormatFilterComplex += $"{ProperLabel(videoLabel)}";
pixelFormatFilterComplex += string.Join(
",",
_filterChain.PixelFormatFilterSteps.Select(f => f.Filter)
.Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[vpf]";
pixelFormatFilterComplex += videoLabel;
}
foreach (AudioInputFile audioInputFile in _maybeAudioInputFile)
{
int inputIndex = distinctPaths.LastIndexOf(audioInputFile.Path);
foreach ((int index, _, _) in audioInputFile.Streams)
{
audioLabel = $"{inputIndex}:{index}";
if (audioInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
audioFilterComplex += $"[{inputIndex}:{index}]";
audioFilterComplex += string.Join(
",",
audioInputFile.FilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
audioLabel = "[a]";
audioFilterComplex += audioLabel;
}
}
}
var filterComplex = string.Join(
";",
new[]
{
audioFilterComplex,
videoFilterComplex,
subtitleFilterComplex,
watermarkFilterComplex,
subtitleOverlayFilterComplex,
watermarkOverlayFilterComplex,
pixelFormatFilterComplex
}.Where(s => !string.IsNullOrWhiteSpace(s)));
if (!string.IsNullOrWhiteSpace(filterComplex))
{
result.AddRange(new[] { "-filter_complex", filterComplex });
}
result.AddRange(new[] { "-map", audioLabel, "-map", videoLabel });
foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(s => s.Copy))
{
int inputIndex = distinctPaths.IndexOf(subtitleInputFile.Path);
foreach ((int index, _, _) in subtitleInputFile.Streams)
{
subtitleLabel = $"{inputIndex}:{index}";
result.AddRange(new[] { "-map", subtitleLabel });
}
}
return result;
}
private string ProperLabel(string label) => label.StartsWith("[") ? label : $"[{label}]";
}

6
ErsatzTV.FFmpeg/Option/MapAllStreamsOutputOption.cs

@ -0,0 +1,6 @@
namespace ErsatzTV.FFmpeg.Option;
public class MapAllStreamsOutputOption : OutputOption
{
public override IList<string> OutputOptions => new[] { "-map", "0" };
}

13
ErsatzTV.FFmpeg/OutputFormat/OutputFormatMpegTs.cs

@ -4,14 +4,21 @@ namespace ErsatzTV.FFmpeg.OutputFormat;
public class OutputFormatMpegTs : IPipelineStep public class OutputFormatMpegTs : IPipelineStep
{ {
private readonly bool _initialDiscontinuity;
public OutputFormatMpegTs(bool initialDiscontinuity = true)
{
_initialDiscontinuity = initialDiscontinuity;
}
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>(); public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>(); public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>(); public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> FilterOptions => Array.Empty<string>(); public IList<string> FilterOptions => Array.Empty<string>();
// always force an initial discontinuity public IList<string> OutputOptions => _initialDiscontinuity
public IList<string> OutputOptions => ? new List<string> { "-f", "mpegts", "-mpegts_flags", "+initial_discontinuity" }
new List<string> { "-f", "mpegts", "-mpegts_flags", "+initial_discontinuity" }; : new List<string> { "-f", "mpegts" };
public FrameState NextState(FrameState currentState) => currentState; public FrameState NextState(FrameState currentState) => currentState;
} }

1
ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs

@ -4,5 +4,6 @@ public interface IPipelineBuilder
{ {
FFmpegPipeline Resize(string outputFile, FrameSize scaledSize); FFmpegPipeline Resize(string outputFile, FrameSize scaledSize);
FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState); FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState);
FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState);
FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState); FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState);
} }

31
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -110,6 +110,31 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
return new FFmpegPipeline(pipelineSteps); return new FFmpegPipeline(pipelineSteps);
} }
public FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState)
{
var pipelineSteps = new List<IPipelineStep>
{
new NoStandardInputOption(),
new ThreadCountOption(1),
new HideBannerOption(),
new LoglevelErrorOption(),
new NoStatsOption(),
new StandardFormatFlags(),
new MapAllStreamsOutputOption(),
new EncoderCopyAll()
};
concatInputFile.AddOption(new RealtimeInputOption());
SetMetadataServiceProvider(ffmpegState, pipelineSteps);
SetMetadataServiceName(ffmpegState, pipelineSteps);
pipelineSteps.Add(new OutputFormatMpegTs(initialDiscontinuity: false));
pipelineSteps.Add(new PipeProtocol());
return new FFmpegPipeline(pipelineSteps);
}
public FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState) public FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState)
{ {
var pipelineSteps = new List<IPipelineStep> var pipelineSteps = new List<IPipelineStep>
@ -180,7 +205,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
SetMetadataAudioLanguage(ffmpegState, pipelineSteps); SetMetadataAudioLanguage(ffmpegState, pipelineSteps);
SetOutputFormat(ffmpegState, desiredState, pipelineSteps, videoStream); SetOutputFormat(ffmpegState, desiredState, pipelineSteps, videoStream);
var complexFilter = new NewComplexFilter( var complexFilter = new ComplexFilter(
_videoInputFile, _videoInputFile,
_audioInputFile, _audioInputFile,
_watermarkInputFile, _watermarkInputFile,
@ -193,7 +218,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
return new FFmpegPipeline(pipelineSteps); return new FFmpegPipeline(pipelineSteps);
} }
protected Option<IDecoder> LogUnknownDecoder( private Option<IDecoder> LogUnknownDecoder(
HardwareAccelerationMode hardwareAccelerationMode, HardwareAccelerationMode hardwareAccelerationMode,
string videoFormat, string videoFormat,
string pixelFormat) string pixelFormat)
@ -206,7 +231,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
return Option<IDecoder>.None; return Option<IDecoder>.None;
} }
protected Option<IEncoder> LogUnknownEncoder(HardwareAccelerationMode hardwareAccelerationMode, string videoFormat) private Option<IEncoder> LogUnknownEncoder(HardwareAccelerationMode hardwareAccelerationMode, string videoFormat)
{ {
_logger.LogWarning( _logger.LogWarning(
"Unable to determine video encoder for {AccelMode} - {VideoFormat}; may have playback issues", "Unable to determine video encoder for {AccelMode} - {VideoFormat}; may have playback issues",

14
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -239,20 +239,6 @@ public class MetadataRepository : IMetadataRepository
() => Task.FromResult(false)); () => Task.FromResult(false));
} }
public async Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaVersion SET
DateUpdated = @DateUpdated
WHERE Id = @MediaVersionId",
new
{
incoming.DateUpdated,
MediaVersionId = mediaVersionId
}).Map(result => result > 0);
}
public async Task<Unit> UpdateArtworkPath(Artwork artwork) public async Task<Unit> UpdateArtworkPath(Artwork artwork)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

8
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -219,14 +219,6 @@ public class MovieRepository : IMovieRepository
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0); new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
} }
public async Task<Unit> UpdatePath(int mediaFileId, string path)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE MediaFile SET Path = @Path WHERE Id = @MediaFileId",
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
}
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie( private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
TvContext dbContext, TvContext dbContext,
int libraryPathId, int libraryPathId,

13
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -2,7 +2,7 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Emby.Models; using ErsatzTV.Infrastructure.Emby.Models;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -13,17 +13,14 @@ namespace ErsatzTV.Infrastructure.Emby;
public class EmbyApiClient : IEmbyApiClient public class EmbyApiClient : IEmbyApiClient
{ {
private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger<EmbyApiClient> _logger; private readonly ILogger<EmbyApiClient> _logger;
private readonly IMemoryCache _memoryCache; private readonly IMemoryCache _memoryCache;
public EmbyApiClient( public EmbyApiClient(
IFallbackMetadataProvider fallbackMetadataProvider,
IMemoryCache memoryCache, IMemoryCache memoryCache,
IEmbyPathReplacementService embyPathReplacementService, IEmbyPathReplacementService embyPathReplacementService,
ILogger<EmbyApiClient> logger) ILogger<EmbyApiClient> logger)
{ {
_fallbackMetadataProvider = fallbackMetadataProvider;
_memoryCache = memoryCache; _memoryCache = memoryCache;
_embyPathReplacementService = embyPathReplacementService; _embyPathReplacementService = embyPathReplacementService;
_logger = logger; _logger = logger;
@ -375,7 +372,7 @@ public class EmbyApiClient : IEmbyApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = item.Name, Title = item.Name,
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), SortTitle = SortTitle.GetSortTitle(item.Name),
Plot = item.Overview, Plot = item.Overview,
Year = item.ProductionYear, Year = item.ProductionYear,
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
@ -500,7 +497,7 @@ public class EmbyApiClient : IEmbyApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = item.Name, Title = item.Name,
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), SortTitle = SortTitle.GetSortTitle(item.Name),
Plot = item.Overview, Plot = item.Overview,
Year = item.ProductionYear, Year = item.ProductionYear,
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
@ -572,7 +569,7 @@ public class EmbyApiClient : IEmbyApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = item.Name, Title = item.Name,
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), SortTitle = SortTitle.GetSortTitle(item.Name),
Year = item.ProductionYear, Year = item.ProductionYear,
DateAdded = dateAdded, DateAdded = dateAdded,
Artwork = new List<Artwork>(), Artwork = new List<Artwork>(),
@ -691,7 +688,7 @@ public class EmbyApiClient : IEmbyApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = item.Name, Title = item.Name,
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), SortTitle = SortTitle.GetSortTitle(item.Name),
Plot = item.Overview, Plot = item.Overview,
Year = item.ProductionYear, Year = item.ProductionYear,
DateAdded = dateAdded, DateAdded = dateAdded,

12
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Jellyfin.Models; using ErsatzTV.Infrastructure.Jellyfin.Models;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -12,19 +13,16 @@ namespace ErsatzTV.Infrastructure.Jellyfin;
public class JellyfinApiClient : IJellyfinApiClient public class JellyfinApiClient : IJellyfinApiClient
{ {
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILogger<JellyfinApiClient> _logger; private readonly ILogger<JellyfinApiClient> _logger;
private readonly IMemoryCache _memoryCache; private readonly IMemoryCache _memoryCache;
public JellyfinApiClient( public JellyfinApiClient(
IMemoryCache memoryCache, IMemoryCache memoryCache,
IFallbackMetadataProvider fallbackMetadataProvider,
IJellyfinPathReplacementService jellyfinPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService,
ILogger<JellyfinApiClient> logger) ILogger<JellyfinApiClient> logger)
{ {
_memoryCache = memoryCache; _memoryCache = memoryCache;
_fallbackMetadataProvider = fallbackMetadataProvider;
_jellyfinPathReplacementService = jellyfinPathReplacementService; _jellyfinPathReplacementService = jellyfinPathReplacementService;
_logger = logger; _logger = logger;
} }
@ -433,7 +431,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = item.Name, Title = item.Name,
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), SortTitle = SortTitle.GetSortTitle(item.Name),
Plot = item.Overview, Plot = item.Overview,
Year = item.ProductionYear, Year = item.ProductionYear,
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
@ -558,7 +556,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = item.Name, Title = item.Name,
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), SortTitle = SortTitle.GetSortTitle(item.Name),
Plot = item.Overview, Plot = item.Overview,
Year = item.ProductionYear, Year = item.ProductionYear,
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
@ -630,7 +628,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = item.Name, Title = item.Name,
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), SortTitle = SortTitle.GetSortTitle(item.Name),
Year = item.ProductionYear, Year = item.ProductionYear,
DateAdded = dateAdded, DateAdded = dateAdded,
Artwork = new List<Artwork>(), Artwork = new List<Artwork>(),
@ -773,7 +771,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = item.Name, Title = item.Name,
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), SortTitle = SortTitle.GetSortTitle(item.Name),
Plot = item.Overview, Plot = item.Overview,
Year = item.ProductionYear, Year = item.ProductionYear,
DateAdded = dateAdded, DateAdded = dateAdded,

16
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -3,6 +3,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex; using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Plex.Models; using ErsatzTV.Infrastructure.Plex.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -12,16 +13,11 @@ namespace ErsatzTV.Infrastructure.Plex;
public class PlexServerApiClient : IPlexServerApiClient public class PlexServerApiClient : IPlexServerApiClient
{ {
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger<PlexServerApiClient> _logger; private readonly ILogger<PlexServerApiClient> _logger;
private readonly PlexEtag _plexEtag; private readonly PlexEtag _plexEtag;
public PlexServerApiClient( public PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient> logger)
IFallbackMetadataProvider fallbackMetadataProvider,
PlexEtag plexEtag,
ILogger<PlexServerApiClient> logger)
{ {
_fallbackMetadataProvider = fallbackMetadataProvider;
_plexEtag = plexEtag; _plexEtag = plexEtag;
_logger = logger; _logger = logger;
} }
@ -454,7 +450,7 @@ public class PlexServerApiClient : IPlexServerApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = response.Title, Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), SortTitle = SortTitle.GetSortTitle(response.Title),
Plot = response.Summary, Plot = response.Summary,
Year = response.Year, Year = response.Year,
Tagline = response.Tagline, Tagline = response.Tagline,
@ -638,7 +634,7 @@ public class PlexServerApiClient : IPlexServerApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = response.Title, Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), SortTitle = SortTitle.GetSortTitle(response.Title),
Plot = response.Summary, Plot = response.Summary,
Year = response.Year, Year = response.Year,
Tagline = response.Tagline, Tagline = response.Tagline,
@ -734,7 +730,7 @@ public class PlexServerApiClient : IPlexServerApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = response.Title, Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), SortTitle = SortTitle.GetSortTitle(response.Title),
Year = response.Year, Year = response.Year,
DateAdded = dateAdded, DateAdded = dateAdded,
DateUpdated = lastWriteTime, DateUpdated = lastWriteTime,
@ -851,7 +847,7 @@ public class PlexServerApiClient : IPlexServerApiClient
{ {
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = response.Title, Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), SortTitle = SortTitle.GetSortTitle(response.Title),
EpisodeNumber = response.Index, EpisodeNumber = response.Index,
Plot = response.Summary, Plot = response.Summary,
Year = response.Year, Year = response.Year,

14
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs → ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using Bugsnag; using Bugsnag;
using CliWrap; using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
@ -20,6 +21,7 @@ using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.Pipeline; using ErsatzTV.FFmpeg.Pipeline;
using ErsatzTV.FFmpeg.State; using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Runtime; using ErsatzTV.Infrastructure.Runtime;
using ErsatzTV.Scanner.Core.Metadata;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -28,7 +30,7 @@ using NUnit.Framework;
using Serilog; using Serilog;
using MediaStream = ErsatzTV.Core.Domain.MediaStream; using MediaStream = ErsatzTV.Core.Domain.MediaStream;
namespace ErsatzTV.Core.Tests.FFmpeg; namespace ErsatzTV.Scanner.Tests.Core.FFmpeg;
[TestFixture] [TestFixture]
[Explicit] [Explicit]
@ -316,13 +318,13 @@ public class TranscodingTests
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle) .Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
.ToList(); .ToList();
var subtitles = new List<Domain.Subtitle>(); var subtitles = new List<ErsatzTV.Core.Domain.Subtitle>();
if (subtitle != Subtitle.None) if (subtitle != Subtitle.None)
{ {
foreach (MediaStream stream in subtitleStreams) foreach (MediaStream stream in subtitleStreams)
{ {
var s = new Domain.Subtitle var s = new ErsatzTV.Core.Domain.Subtitle
{ {
Codec = stream.Codec, Codec = stream.Codec,
Default = stream.Default, Default = stream.Default,
@ -427,7 +429,7 @@ public class TranscodingTests
{ {
// validate pipeline matches expectations (at a high level) // validate pipeline matches expectations (at a high level)
NewComplexFilter complexFilter = pipeline.PipelineSteps.OfType<NewComplexFilter>().First(); ComplexFilter complexFilter = pipeline.PipelineSteps.OfType<ComplexFilter>().First();
FilterChain filterChain = complexFilter.FilterChain; FilterChain filterChain = complexFilter.FilterChain;
if (profileBitDepth == FFmpegProfileBitDepth.TenBit) if (profileBitDepth == FFmpegProfileBitDepth.TenBit)
@ -800,8 +802,8 @@ public class TranscodingTests
string preferredAudioTitle) => string preferredAudioTitle) =>
Optional(version.MediaVersion.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask(); Optional(version.MediaVersion.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
public Task<Option<Domain.Subtitle>> SelectSubtitleStream( public Task<Option<ErsatzTV.Core.Domain.Subtitle>> SelectSubtitleStream(
List<Domain.Subtitle> subtitles, List<ErsatzTV.Core.Domain.Subtitle> subtitles,
Channel channel, Channel channel,
string preferredSubtitleLanguage, string preferredSubtitleLanguage,
ChannelSubtitleMode subtitleMode) => ChannelSubtitleMode subtitleMode) =>

18
ErsatzTV.Core.Tests/Metadata/LocalStatisticsProviderTests.cs → ErsatzTV.Scanner.Tests/Core/Metadata/LocalStatisticsProviderTests.cs

@ -2,13 +2,13 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Scanner.Core.Metadata;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata; namespace ErsatzTV.Scanner.Tests.Core.Metadata;
[TestFixture] [TestFixture]
public class LocalStatisticsProviderTests public class LocalStatisticsProviderTests
@ -25,7 +25,19 @@ public class LocalStatisticsProviderTests
new Mock<ILogger<LocalStatisticsProvider>>().Object); new Mock<ILogger<LocalStatisticsProvider>>().Object);
var input = new LocalStatisticsProvider.FFprobe( var input = new LocalStatisticsProvider.FFprobe(
new LocalStatisticsProvider.FFprobeFormat("123.45", null), new LocalStatisticsProvider.FFprobeFormat(
"123.45",
new LocalStatisticsProvider.FFprobeTags(
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty)),
new List<LocalStatisticsProvider.FFprobeStream>(), new List<LocalStatisticsProvider.FFprobeStream>(),
new List<LocalStatisticsProvider.FFprobeChapter>()); new List<LocalStatisticsProvider.FFprobeChapter>());

6
ErsatzTV.Core.Tests/Metadata/LocalSubtitlesProviderTests.cs → ErsatzTV.Scanner.Tests/Core/Metadata/LocalSubtitlesProviderTests.cs

@ -1,14 +1,14 @@
using System.Globalization; using System.Globalization;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Scanner.Core.Metadata;
using ErsatzTV.Core.Tests.Fakes; using ErsatzTV.Scanner.Tests.Core.Fakes;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata; namespace ErsatzTV.Scanner.Tests.Core.Metadata;
[TestFixture] [TestFixture]
public class LocalSubtitlesProviderTests public class LocalSubtitlesProviderTests

1
ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs

@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.FFmpeg; using ErsatzTV.Scanner.Core.Interfaces.FFmpeg;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Tests.Core.Fakes; using ErsatzTV.Scanner.Tests.Core.Fakes;
using ErsatzTV.Scanner.Core.Metadata; using ErsatzTV.Scanner.Core.Metadata;
using FluentAssertions; using FluentAssertions;

5
ErsatzTV.Core.Tests/Metadata/Nfo/ArtistNfoReaderTests.cs → ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/ArtistNfoReaderTests.cs

@ -1,6 +1,7 @@
using System.Text; using System.Text;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo; using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.IO; using Microsoft.IO;
@ -8,7 +9,7 @@ using Moq;
using NUnit.Framework; using NUnit.Framework;
using Serilog; using Serilog;
namespace ErsatzTV.Core.Tests.Metadata.Nfo; namespace ErsatzTV.Scanner.Tests.Core.Metadata.Nfo;
[TestFixture] [TestFixture]
public class ArtistNfoReaderTests public class ArtistNfoReaderTests

53
ErsatzTV.Core.Tests/Metadata/Nfo/EpisodeNfoReaderTests.cs → ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/EpisodeNfoReaderTests.cs

@ -1,6 +1,7 @@
using System.Text; using System.Text;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo; using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.IO; using Microsoft.IO;
@ -8,7 +9,7 @@ using Moq;
using NUnit.Framework; using NUnit.Framework;
using Serilog; using Serilog;
namespace ErsatzTV.Core.Tests.Metadata.Nfo; namespace ErsatzTV.Scanner.Tests.Core.Metadata.Nfo;
[TestFixture] [TestFixture]
public class EpisodeNfoReaderTests public class EpisodeNfoReaderTests
@ -45,10 +46,10 @@ public class EpisodeNfoReaderTests
<episodedetails> <episodedetails>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(1); list.Count.Should().Be(1);
} }
@ -74,10 +75,10 @@ public class EpisodeNfoReaderTests
<season>1</season> <season>1</season>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(2); list.Count.Should().Be(2);
list.All(nfo => nfo.ShowTitle == "show").Should().BeTrue(); list.All(nfo => nfo.ShowTitle == "show").Should().BeTrue();
@ -99,10 +100,10 @@ public class EpisodeNfoReaderTests
<uniqueid default=""false"" type=""imdb"">tt54321</uniqueid> <uniqueid default=""false"" type=""imdb"">tt54321</uniqueid>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(1); list.Count.Should().Be(1);
list[0].UniqueIds.Count.Should().Be(2); list[0].UniqueIds.Count.Should().Be(2);
@ -122,10 +123,10 @@ public class EpisodeNfoReaderTests
<mpaa/> <mpaa/>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(1); list.Count.Should().Be(1);
list[0].ContentRating.Should().BeNullOrEmpty(); list[0].ContentRating.Should().BeNullOrEmpty();
@ -146,10 +147,10 @@ public class EpisodeNfoReaderTests
<mpaa>US:Something / US:SomethingElse</mpaa> <mpaa>US:Something / US:SomethingElse</mpaa>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(2); list.Count.Should().Be(2);
list.Count(nfo => nfo.ContentRating == "US:Something").Should().Be(1); list.Count(nfo => nfo.ContentRating == "US:Something").Should().Be(1);
@ -168,10 +169,10 @@ public class EpisodeNfoReaderTests
<plot/> <plot/>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(1); list.Count.Should().Be(1);
list[0].Plot.Should().BeNullOrEmpty(); list[0].Plot.Should().BeNullOrEmpty();
@ -189,10 +190,10 @@ public class EpisodeNfoReaderTests
<plot>Some Plot</plot> <plot>Some Plot</plot>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(1); list.Count.Should().Be(1);
list[0].Plot.Should().Be("Some Plot"); list[0].Plot.Should().Be("Some Plot");
@ -219,10 +220,10 @@ public class EpisodeNfoReaderTests
</actor> </actor>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(1); list.Count.Should().Be(1);
list[0].Actors.Count.Should().Be(2); list[0].Actors.Count.Should().Be(2);
@ -248,10 +249,10 @@ public class EpisodeNfoReaderTests
<credits>Writer 3</credits> <credits>Writer 3</credits>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(2); list.Count.Should().Be(2);
list.Count(nfo => nfo.Writers.Count == 1 && nfo.Writers[0] == "Writer 1").Should().Be(1); list.Count(nfo => nfo.Writers.Count == 1 && nfo.Writers[0] == "Writer 1").Should().Be(1);
@ -275,10 +276,10 @@ public class EpisodeNfoReaderTests
<director>Director 3</director> <director>Director 3</director>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(2); list.Count.Should().Be(2);
list.Count(nfo => nfo.Directors.Count == 1 && nfo.Directors[0] == "Director 1").Should().Be(1); list.Count(nfo => nfo.Directors.Count == 1 && nfo.Directors[0] == "Director 1").Should().Be(1);
@ -379,11 +380,11 @@ public class EpisodeNfoReaderTests
<dateadded>2021-02-02 11:57:44</dateadded> <dateadded>2021-02-02 11:57:44</dateadded>
</episodedetails>")); </episodedetails>"));
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.Read(stream); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (TvShowEpisodeNfo nfo in result.RightToSeq().Flatten()) foreach (EpisodeNfo nfo in result.RightToSeq().Flatten())
{ {
nfo.ShowTitle.Should().Be("WandaVision"); nfo.ShowTitle.Should().Be("WandaVision");
nfo.Title.Should().Be("Filmed Before a Live Studio Audience"); nfo.Title.Should().Be("Filmed Before a Live Studio Audience");
@ -433,10 +434,10 @@ public class EpisodeNfoReaderTests
"Resources", "Resources",
"Nfo", "Nfo",
"EpisodeInvalidCharacters.nfo"); "EpisodeInvalidCharacters.nfo");
Either<BaseError, List<TvShowEpisodeNfo>> result = await _episodeNfoReader.ReadFromFile(sourceFile); Either<BaseError, List<EpisodeNfo>> result = await _episodeNfoReader.ReadFromFile(sourceFile);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (List<TvShowEpisodeNfo> list in result.RightToSeq()) foreach (List<EpisodeNfo> list in result.RightToSeq())
{ {
list.Count.Should().Be(1); list.Count.Should().Be(1);
list[0].Title.Should().Be("Test Title"); list[0].Title.Should().Be("Test Title");

7
ErsatzTV.Core.Tests/Metadata/Nfo/MovieNfoReaderTests.cs → ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/MovieNfoReaderTests.cs

@ -1,13 +1,14 @@
using System.Text; using System.Text;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo; using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO; using Microsoft.IO;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo; namespace ErsatzTV.Scanner.Tests.Core.Metadata.Nfo;
[TestFixture] [TestFixture]
public class MovieNfoReaderTests public class MovieNfoReaderTests
@ -187,7 +188,7 @@ https://www.themoviedb.org/movie/11-star-wars"));
nfo.Plot.Should().Be( nfo.Plot.Should().Be(
"Determined to ensure Superman's ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions."); "Determined to ensure Superman's ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.");
nfo.Tagline.Should().BeNullOrEmpty(); // nfo.Tagline.Should().BeNullOrEmpty();
nfo.Genres.Should().BeEquivalentTo(new List<string> { "SuperHero" }); nfo.Genres.Should().BeEquivalentTo(new List<string> { "SuperHero" });
nfo.Tags.Should().BeEquivalentTo(new List<string> { "TV Recording" }); nfo.Tags.Should().BeEquivalentTo(new List<string> { "TV Recording" });
nfo.Studios.Should().BeEquivalentTo(new List<string> { "Warner Bros. Pictures" }); nfo.Studios.Should().BeEquivalentTo(new List<string> { "Warner Bros. Pictures" });

5
ErsatzTV.Core.Tests/Metadata/Nfo/MusicVideoNfoReaderTests.cs → ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/MusicVideoNfoReaderTests.cs

@ -1,13 +1,14 @@
using System.Text; using System.Text;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo; using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO; using Microsoft.IO;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo; namespace ErsatzTV.Scanner.Tests.Core.Metadata.Nfo;
[TestFixture] [TestFixture]
public class MusicVideoNfoReaderTests public class MusicVideoNfoReaderTests

5
ErsatzTV.Core.Tests/Metadata/Nfo/OtherVideoNfoReaderTests.cs → ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/OtherVideoNfoReaderTests.cs

@ -1,13 +1,14 @@
using System.Text; using System.Text;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo; using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO; using Microsoft.IO;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo; namespace ErsatzTV.Scanner.Tests.Core.Metadata.Nfo;
[TestFixture] [TestFixture]
public class OtherVideoNfoReaderTests public class OtherVideoNfoReaderTests

35
ErsatzTV.Core.Tests/Metadata/Nfo/TvShowNfoReaderTests.cs → ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/ShowNfoReaderTests.cs

@ -1,24 +1,25 @@
using System.Text; using System.Text;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo; using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO; using Microsoft.IO;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo; namespace ErsatzTV.Scanner.Tests.Core.Metadata.Nfo;
[TestFixture] [TestFixture]
public class TvShowNfoReaderTests public class ShowNfoReaderTests
{ {
[SetUp] [SetUp]
public void SetUp() => _tvShowNfoReader = new TvShowNfoReader( public void SetUp() => _showNfoReader = new ShowNfoReader(
new RecyclableMemoryStreamManager(), new RecyclableMemoryStreamManager(),
new Mock<IClient>().Object, new Mock<IClient>().Object,
new NullLogger<TvShowNfoReader>()); new NullLogger<ShowNfoReader>());
private TvShowNfoReader _tvShowNfoReader; private ShowNfoReader _showNfoReader;
[Test] [Test]
public async Task ParsingNfo_Should_Return_Error() public async Task ParsingNfo_Should_Return_Error()
@ -26,7 +27,7 @@ public class TvShowNfoReaderTests
await using var stream = await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars")); new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream); Either<BaseError, ShowNfo> result = await _showNfoReader.Read(stream);
result.IsLeft.Should().BeTrue(); result.IsLeft.Should().BeTrue();
} }
@ -36,7 +37,7 @@ public class TvShowNfoReaderTests
{ {
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow></tvshow>")); await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow></tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream); Either<BaseError, ShowNfo> result = await _showNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
} }
@ -49,7 +50,7 @@ public class TvShowNfoReaderTests
@"<tvshow></tvshow> @"<tvshow></tvshow>
https://www.themoviedb.org/movie/11-star-wars")); https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream); Either<BaseError, ShowNfo> result = await _showNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
} }
@ -133,11 +134,11 @@ https://www.themoviedb.org/movie/11-star-wars"));
<dateadded>2021-03-12 06:15:51</dateadded> <dateadded>2021-03-12 06:15:51</dateadded>
</tvshow>")); </tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream); Either<BaseError, ShowNfo> result = await _showNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (TvShowNfo nfo in result.RightToSeq()) foreach (ShowNfo nfo in result.RightToSeq())
{ {
nfo.Title.Should().Be("WandaVision"); nfo.Title.Should().Be("WandaVision");
nfo.Year.Should().Be(2021); nfo.Year.Should().Be(2021);
@ -181,10 +182,10 @@ https://www.themoviedb.org/movie/11-star-wars"));
await using var stream = await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow><outline>Test Outline</outline></tvshow>")); new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow><outline>Test Outline</outline></tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream); Either<BaseError, ShowNfo> result = await _showNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (TvShowNfo nfo in result.RightToSeq()) foreach (ShowNfo nfo in result.RightToSeq())
{ {
nfo.Outline.Should().Be("Test Outline"); nfo.Outline.Should().Be("Test Outline");
} }
@ -196,10 +197,10 @@ https://www.themoviedb.org/movie/11-star-wars"));
await using var stream = await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow><tagline>Test Tagline</tagline></tvshow>")); new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow><tagline>Test Tagline</tagline></tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream); Either<BaseError, ShowNfo> result = await _showNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (TvShowNfo nfo in result.RightToSeq()) foreach (ShowNfo nfo in result.RightToSeq())
{ {
nfo.Tagline.Should().Be("Test Tagline"); nfo.Tagline.Should().Be("Test Tagline");
} }
@ -210,10 +211,10 @@ https://www.themoviedb.org/movie/11-star-wars"));
{ {
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow><tag>Test Tag</tag></tvshow>")); await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<tvshow><tag>Test Tag</tag></tvshow>"));
Either<BaseError, TvShowNfo> result = await _tvShowNfoReader.Read(stream); Either<BaseError, ShowNfo> result = await _showNfoReader.Read(stream);
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
foreach (TvShowNfo nfo in result.RightToSeq()) foreach (ShowNfo nfo in result.RightToSeq())
{ {
nfo.Tags.Should().BeEquivalentTo(new List<string> { "Test Tag" }); nfo.Tags.Should().BeEquivalentTo(new List<string> { "Test Tag" });
} }

12
ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj

@ -23,4 +23,16 @@
<ProjectReference Include="..\ErsatzTV.Scanner\ErsatzTV.Scanner.csproj" /> <ProjectReference Include="..\ErsatzTV.Scanner\ErsatzTV.Scanner.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Include="Resources\Nfo\ArtistInvalidCharacters1.nfo">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\Nfo\ArtistInvalidCharacters2.nfo">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\Nfo\EpisodeInvalidCharacters.nfo">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project> </Project>

0
ErsatzTV.Core.Tests/Resources/Nfo/ArtistInvalidCharacters1.nfo → ErsatzTV.Scanner.Tests/Resources/Nfo/ArtistInvalidCharacters1.nfo

0
ErsatzTV.Core.Tests/Resources/Nfo/ArtistInvalidCharacters2.nfo → ErsatzTV.Scanner.Tests/Resources/Nfo/ArtistInvalidCharacters2.nfo

0
ErsatzTV.Core.Tests/Resources/Nfo/EpisodeInvalidCharacters.nfo → ErsatzTV.Scanner.Tests/Resources/Nfo/EpisodeInvalidCharacters.nfo

2
ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs

@ -1,9 +1,9 @@
using System.Diagnostics; using System.Diagnostics;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources; using ErsatzTV.Core.MediaSources;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Humanizer; using Humanizer;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;

4
ErsatzTV.Scanner/Core/Emby/EmbyCollectionScanner.cs

@ -55,10 +55,6 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
_logger.LogDebug("Emby collection {Name} is new", collection.Name); _logger.LogDebug("Emby collection {Name} is new", collection.Name);
await _embyCollectionRepository.AddCollection(collection); await _embyCollectionRepository.AddCollection(collection);
} }
else
{
_logger.LogDebug("Emby collection {Name} has been updated", collection.Name);
}
await SyncCollectionItems(address, apiKey, collection); await SyncCollectionItems(address, apiKey, collection);

2
ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs

@ -6,6 +6,8 @@ using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Emby; namespace ErsatzTV.Scanner.Core.Emby;

2
ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -6,6 +6,8 @@ using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Emby; namespace ErsatzTV.Scanner.Core.Emby;

2
ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs → ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -1,6 +1,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface ILocalMetadataProvider public interface ILocalMetadataProvider
{ {

5
ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs → ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalStatisticsProvider.cs

@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface ILocalStatisticsProvider public interface ILocalStatisticsProvider
{ {

2
ErsatzTV.Core/Interfaces/Metadata/ILocalSubtitlesProvider.cs → ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalSubtitlesProvider.cs

@ -1,6 +1,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface ILocalSubtitlesProvider public interface ILocalSubtitlesProvider
{ {

5
ErsatzTV.Core/Interfaces/Metadata/IMovieFolderScanner.cs → ErsatzTV.Scanner/Core/Interfaces/Metadata/IMovieFolderScanner.cs

@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface IMovieFolderScanner public interface IMovieFolderScanner
{ {

5
ErsatzTV.Core/Interfaces/Metadata/IMusicVideoFolderScanner.cs → ErsatzTV.Scanner/Core/Interfaces/Metadata/IMusicVideoFolderScanner.cs

@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface IMusicVideoFolderScanner public interface IMusicVideoFolderScanner
{ {

5
ErsatzTV.Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs → ErsatzTV.Scanner/Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs

@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface IOtherVideoFolderScanner public interface IOtherVideoFolderScanner
{ {

5
ErsatzTV.Core/Interfaces/Metadata/ISongFolderScanner.cs → ErsatzTV.Scanner/Core/Interfaces/Metadata/ISongFolderScanner.cs

@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface ISongFolderScanner public interface ISongFolderScanner
{ {

5
ErsatzTV.Core/Interfaces/Metadata/ITelevisionFolderScanner.cs → ErsatzTV.Scanner/Core/Interfaces/Metadata/ITelevisionFolderScanner.cs

@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface ITelevisionFolderScanner public interface ITelevisionFolderScanner
{ {

9
ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IArtistNfoReader.cs

@ -0,0 +1,9 @@
using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
public interface IArtistNfoReader
{
Task<Either<BaseError, ArtistNfo>> ReadFromFile(string fileName);
}

9
ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs

@ -0,0 +1,9 @@
using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
public interface IEpisodeNfoReader
{
Task<Either<BaseError, List<EpisodeNfo>>> ReadFromFile(string fileName);
}

9
ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IMovieNfoReader.cs

@ -0,0 +1,9 @@
using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
public interface IMovieNfoReader
{
Task<Either<BaseError, MovieNfo>> ReadFromFile(string fileName);
}

9
ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IMusicVideoNfoReader.cs

@ -0,0 +1,9 @@
using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
public interface IMusicVideoNfoReader
{
Task<Either<BaseError, MusicVideoNfo>> ReadFromFile(string fileName);
}

9
ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IOtherVideoNfoReader.cs

@ -0,0 +1,9 @@
using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
public interface IOtherVideoNfoReader
{
Task<Either<BaseError, OtherVideoNfo>> ReadFromFile(string fileName);
}

9
ErsatzTV.Scanner/Core/Interfaces/Metadata/Nfo/IShowNfoReader.cs

@ -0,0 +1,9 @@
using ErsatzTV.Core;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
public interface IShowNfoReader
{
Task<Either<BaseError, ShowNfo>> ReadFromFile(string fileName);
}

4
ErsatzTV.Scanner/Core/Jellyfin/JellyfinCollectionScanner.cs

@ -58,10 +58,6 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
_logger.LogDebug("Jellyfin collection {Name} is new", collection.Name); _logger.LogDebug("Jellyfin collection {Name} is new", collection.Name);
await _jellyfinCollectionRepository.AddCollection(collection); await _jellyfinCollectionRepository.AddCollection(collection);
} }
else
{
_logger.LogDebug("Updating Jellyfin collection {Name}", collection.Name);
}
await SyncCollectionItems(address, apiKey, mediaSourceId, collection); await SyncCollectionItems(address, apiKey, mediaSourceId, collection);

2
ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs

@ -6,6 +6,8 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Jellyfin; namespace ErsatzTV.Scanner.Core.Jellyfin;

2
ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -6,6 +6,8 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Jellyfin; namespace ErsatzTV.Scanner.Core.Jellyfin;

2
ErsatzTV.Core/Metadata/FolderEtag.cs → ErsatzTV.Scanner/Core/Metadata/FolderEtag.cs

@ -2,7 +2,7 @@
using System.Text; using System.Text;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Scanner.Core.Metadata;
public static class FolderEtag public static class FolderEtag
{ {

1
ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs

@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.FFmpeg; using ErsatzTV.Scanner.Core.Interfaces.FFmpeg;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Metadata; namespace ErsatzTV.Scanner.Core.Metadata;

79
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs → ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs

@ -1,13 +1,16 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata.Nfo; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
using ErsatzTV.Scanner.Core.Metadata.Nfo;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Scanner.Core.Metadata;
public class LocalMetadataProvider : ILocalMetadataProvider public class LocalMetadataProvider : ILocalMetadataProvider
{ {
@ -28,7 +31,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
private readonly IOtherVideoRepository _otherVideoRepository; private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ISongRepository _songRepository; private readonly ISongRepository _songRepository;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
private readonly ITvShowNfoReader _tvShowNfoReader; private readonly IShowNfoReader _showNfoReader;
public LocalMetadataProvider( public LocalMetadataProvider(
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
@ -44,7 +47,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
IEpisodeNfoReader episodeNfoReader, IEpisodeNfoReader episodeNfoReader,
IArtistNfoReader artistNfoReader, IArtistNfoReader artistNfoReader,
IMusicVideoNfoReader musicVideoNfoReader, IMusicVideoNfoReader musicVideoNfoReader,
ITvShowNfoReader tvShowNfoReader, IShowNfoReader showNfoReader,
IOtherVideoNfoReader otherVideoNfoReader, IOtherVideoNfoReader otherVideoNfoReader,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
IClient client, IClient client,
@ -63,7 +66,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
_episodeNfoReader = episodeNfoReader; _episodeNfoReader = episodeNfoReader;
_artistNfoReader = artistNfoReader; _artistNfoReader = artistNfoReader;
_musicVideoNfoReader = musicVideoNfoReader; _musicVideoNfoReader = musicVideoNfoReader;
_tvShowNfoReader = tvShowNfoReader; _showNfoReader = showNfoReader;
_otherVideoNfoReader = otherVideoNfoReader; _otherVideoNfoReader = otherVideoNfoReader;
_localStatisticsProvider = localStatisticsProvider; _localStatisticsProvider = localStatisticsProvider;
_client = client; _client = client;
@ -81,12 +84,12 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (ShowMetadata metadata in maybeMetadata) foreach (ShowMetadata metadata in maybeMetadata)
{ {
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title); metadata.SortTitle = SortTitle.GetSortTitle(metadata.Title);
return metadata; return metadata;
} }
ShowMetadata fallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder); ShowMetadata fallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder);
fallbackMetadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(fallbackMetadata.Title); fallbackMetadata.SortTitle = SortTitle.GetSortTitle(fallbackMetadata.Title);
return fallbackMetadata; return fallbackMetadata;
} }
@ -101,12 +104,12 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (ArtistMetadata metadata in maybeMetadata) foreach (ArtistMetadata metadata in maybeMetadata)
{ {
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title); metadata.SortTitle = SortTitle.GetSortTitle(metadata.Title);
return metadata; return metadata;
} }
ArtistMetadata fallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder); ArtistMetadata fallbackMetadata = _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder);
fallbackMetadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(fallbackMetadata.Title); fallbackMetadata.SortTitle = SortTitle.GetSortTitle(fallbackMetadata.Title);
return fallbackMetadata; return fallbackMetadata;
} }
@ -297,37 +300,37 @@ public class LocalMetadataProvider : ILocalMetadataProvider
Tags = new List<Tag>() Tags = new List<Tag>()
}; };
if (tags.TryGetValue(MetadataSongTag.Album, out string album)) if (tags.TryGetValue(MetadataSongTag.Album, out string? album))
{ {
result.Album = album; result.Album = album;
} }
if (tags.TryGetValue(MetadataSongTag.Artist, out string artist)) if (tags.TryGetValue(MetadataSongTag.Artist, out string? artist))
{ {
result.Artist = artist; result.Artist = artist;
} }
if (tags.TryGetValue(MetadataSongTag.AlbumArtist, out string albumArtist)) if (tags.TryGetValue(MetadataSongTag.AlbumArtist, out string? albumArtist))
{ {
result.AlbumArtist = albumArtist; result.AlbumArtist = albumArtist;
} }
if (tags.TryGetValue(MetadataSongTag.Date, out string date)) if (tags.TryGetValue(MetadataSongTag.Date, out string? date))
{ {
result.Date = date; result.Date = date;
} }
if (tags.TryGetValue(MetadataSongTag.Genre, out string genre)) if (tags.TryGetValue(MetadataSongTag.Genre, out string? genre))
{ {
result.Genres.AddRange(SplitGenres(genre).Map(n => new Genre { Name = n })); result.Genres.AddRange(SplitGenres(genre).Map(n => new Genre { Name = n }));
} }
if (tags.TryGetValue(MetadataSongTag.Title, out string title)) if (tags.TryGetValue(MetadataSongTag.Title, out string? title))
{ {
result.Title = title; result.Title = title;
} }
if (tags.TryGetValue(MetadataSongTag.Track, out string track)) if (tags.TryGetValue(MetadataSongTag.Track, out string? track))
{ {
result.Track = track; result.Track = track;
} }
@ -384,7 +387,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (EpisodeMetadata metadata in toAdd) foreach (EpisodeMetadata metadata in toAdd)
{ {
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
metadata.EpisodeId = episode.Id; metadata.EpisodeId = episode.Id;
metadata.Episode = episode; metadata.Episode = episode;
@ -415,7 +418,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
existing.ReleaseDate = metadata.ReleaseDate; existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year; existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
updated = await UpdateMetadataCollections( updated = await UpdateMetadataCollections(
@ -515,7 +518,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
existing.ReleaseDate = metadata.ReleaseDate; existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year; existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
bool updated = await UpdateMetadataCollections( bool updated = await UpdateMetadataCollections(
@ -590,7 +593,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
metadata.MovieId = movie.Id; metadata.MovieId = movie.Id;
movie.MovieMetadata = new List<MovieMetadata> { metadata }; movie.MovieMetadata = new List<MovieMetadata> { metadata };
@ -620,7 +623,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
existing.ReleaseDate = metadata.ReleaseDate; existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year; existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
bool updated = await UpdateMetadataCollections( bool updated = await UpdateMetadataCollections(
@ -655,7 +658,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
metadata.ShowId = show.Id; metadata.ShowId = show.Id;
show.ShowMetadata = new List<ShowMetadata> { metadata }; show.ShowMetadata = new List<ShowMetadata> { metadata };
@ -680,7 +683,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
existing.DateUpdated = metadata.DateUpdated; existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind; existing.MetadataKind = metadata.MetadataKind;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
var updated = false; var updated = false;
@ -749,7 +752,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
metadata.ArtistId = artist.Id; metadata.ArtistId = artist.Id;
artist.ArtistMetadata = new List<ArtistMetadata> { metadata }; artist.ArtistMetadata = new List<ArtistMetadata> { metadata };
@ -778,7 +781,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
existing.OriginalTitle = metadata.OriginalTitle; existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate; existing.ReleaseDate = metadata.ReleaseDate;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
bool updated = await UpdateMetadataCollections( bool updated = await UpdateMetadataCollections(
@ -815,7 +818,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
metadata.MusicVideoId = musicVideo.Id; metadata.MusicVideoId = musicVideo.Id;
musicVideo.MusicVideoMetadata = new List<MusicVideoMetadata> { metadata }; musicVideo.MusicVideoMetadata = new List<MusicVideoMetadata> { metadata };
@ -845,7 +848,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
existing.ReleaseDate = metadata.ReleaseDate; existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year; existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
existing.OriginalTitle = metadata.OriginalTitle; existing.OriginalTitle = metadata.OriginalTitle;
@ -921,7 +924,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
metadata.OtherVideoId = otherVideo.Id; metadata.OtherVideoId = otherVideo.Id;
otherVideo.OtherVideoMetadata = new List<OtherVideoMetadata> { metadata }; otherVideo.OtherVideoMetadata = new List<OtherVideoMetadata> { metadata };
@ -949,7 +952,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
existing.DateUpdated = metadata.DateUpdated; existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind; existing.MetadataKind = metadata.MetadataKind;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
existing.OriginalTitle = metadata.OriginalTitle; existing.OriginalTitle = metadata.OriginalTitle;
@ -965,7 +968,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
metadata.SongId = song.Id; metadata.SongId = song.Id;
song.SongMetadata = new List<SongMetadata> { metadata }; song.SongMetadata = new List<SongMetadata> { metadata };
@ -977,7 +980,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{ {
try try
{ {
Either<BaseError, TvShowNfo> maybeNfo = await _tvShowNfoReader.ReadFromFile(nfoFileName); Either<BaseError, ShowNfo> maybeNfo = await _showNfoReader.ReadFromFile(nfoFileName);
foreach (BaseError error in maybeNfo.LeftToSeq()) foreach (BaseError error in maybeNfo.LeftToSeq())
{ {
_logger.LogInformation( _logger.LogInformation(
@ -986,7 +989,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
error.ToString()); error.ToString());
} }
foreach (TvShowNfo nfo in maybeNfo.RightToSeq()) foreach (ShowNfo nfo in maybeNfo.RightToSeq())
{ {
DateTime dateAdded = DateTime.UtcNow; DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
@ -1066,7 +1069,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{ {
try try
{ {
Either<BaseError, List<TvShowEpisodeNfo>> maybeNfo = await _episodeNfoReader.ReadFromFile(nfoFileName); Either<BaseError, List<EpisodeNfo>> maybeNfo = await _episodeNfoReader.ReadFromFile(nfoFileName);
foreach (BaseError error in maybeNfo.LeftToSeq()) foreach (BaseError error in maybeNfo.LeftToSeq())
{ {
_logger.LogInformation( _logger.LogInformation(
@ -1076,7 +1079,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
var result = new List<EpisodeMetadata>(); var result = new List<EpisodeMetadata>();
foreach (TvShowEpisodeNfo nfo in maybeNfo.RightToSeq().Flatten()) foreach (EpisodeNfo nfo in maybeNfo.RightToSeq().Flatten())
{ {
DateTime dateAdded = DateTime.UtcNow; DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
@ -1087,7 +1090,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
DateAdded = dateAdded, DateAdded = dateAdded,
DateUpdated = dateUpdated, DateUpdated = dateUpdated,
Title = nfo.Title, Title = nfo.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(nfo.Title), SortTitle = SortTitle.GetSortTitle(nfo.Title),
EpisodeNumber = nfo.Episode, EpisodeNumber = nfo.Episode,
Year = GetYear(0, nfo.Aired), Year = GetYear(0, nfo.Aired),
ReleaseDate = GetAired(0, nfo.Aired), ReleaseDate = GetAired(0, nfo.Aired),
@ -1169,7 +1172,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
ReleaseDate = releaseDate, ReleaseDate = releaseDate,
Plot = nfo.Plot, Plot = nfo.Plot,
Outline = nfo.Outline, Outline = nfo.Outline,
Tagline = nfo.Tagline, // Tagline = nfo.Tagline,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(), Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(), Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(), Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(),
@ -1300,7 +1303,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
Func<T, Tag, Task<bool>> addTag, Func<T, Tag, Task<bool>> addTag,
Func<T, Studio, Task<bool>> addStudio, Func<T, Studio, Task<bool>> addStudio,
Func<T, Actor, Task<bool>> addActor) Func<T, Actor, Task<bool>> addActor)
where T : Domain.Metadata where T : ErsatzTV.Core.Domain.Metadata
{ {
var updated = false; var updated = false;

93
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs → ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs

@ -5,14 +5,16 @@ using System.Text.RegularExpressions;
using Bugsnag; using Bugsnag;
using CliWrap; using CliWrap;
using CliWrap.Buffered; using CliWrap.Buffered;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Scanner.Core.Metadata;
public class LocalStatisticsProvider : ILocalStatisticsProvider public class LocalStatisticsProvider : ILocalStatisticsProvider
{ {
@ -237,9 +239,8 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
return BaseError.New($"FFprobe at {ffprobePath} exited with code {probe.ExitCode}"); return BaseError.New($"FFprobe at {ffprobePath} exited with code {probe.ExitCode}");
} }
FFprobe ffprobe = JsonConvert.DeserializeObject<FFprobe>(probe.StandardOutput); FFprobe? ffprobe = JsonConvert.DeserializeObject<FFprobe>(probe.StandardOutput);
if (ffprobe is not null)
if (ffprobe != null)
{ {
const string PATTERN = @"\[SAR\s+([0-9]+:[0-9]+)\s+DAR\s+([0-9]+:[0-9]+)\]"; const string PATTERN = @"\[SAR\s+([0-9]+:[0-9]+)\s+DAR\s+([0-9]+:[0-9]+)\]";
Match match = Regex.Match(probe.StandardError, PATTERN); Match match = Regex.Match(probe.StandardError, PATTERN);
@ -247,16 +248,18 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
{ {
string sar = match.Groups[1].Value; string sar = match.Groups[1].Value;
string dar = match.Groups[2].Value; string dar = match.Groups[2].Value;
foreach (FFprobeStream stream in ffprobe.streams.Where(s => s.codec_type == "video").ToList()) foreach (FFprobeStream stream in Optional(ffprobe.streams?.Where(s => s.codec_type == "video")).Flatten())
{ {
FFprobeStream replacement = stream with { sample_aspect_ratio = sar, display_aspect_ratio = dar }; FFprobeStream replacement = stream with { sample_aspect_ratio = sar, display_aspect_ratio = dar };
ffprobe.streams.Remove(stream); ffprobe.streams?.Remove(stream);
ffprobe.streams.Add(replacement); ffprobe.streams?.Add(replacement);
} }
} }
return ffprobe;
} }
return ffprobe; return BaseError.New("Unable to deserialize ffprobe output");
} }
private async Task AnalyzeDuration(string ffmpegPath, string path, MediaVersion version) private async Task AnalyzeDuration(string ffmpegPath, string path, MediaVersion version)
@ -322,7 +325,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
internal MediaVersion ProjectToMediaVersion(string path, FFprobe probeOutput) => internal MediaVersion ProjectToMediaVersion(string path, FFprobe probeOutput) =>
Optional(probeOutput) Optional(probeOutput)
.Filter(json => json?.format != null && json.streams != null) .Filter(json => json is { format: { }, streams: { } })
.ToValidation<BaseError>("Unable to parse ffprobe output") .ToValidation<BaseError>("Unable to parse ffprobe output")
.ToEither<FFprobe>() .ToEither<FFprobe>()
.Match( .Match(
@ -337,7 +340,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
}; };
if (double.TryParse( if (double.TryParse(
json.format.duration, json.format?.duration,
NumberStyles.Number, NumberStyles.Number,
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
out double duration)) out double duration))
@ -380,7 +383,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
version.Streams.Add(stream); version.Streams.Add(stream);
} }
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video"); FFprobeStream? videoStream = json.streams?.FirstOrDefault(s => s.codec_type == "video");
if (videoStream != null) if (videoStream != null)
{ {
version.SampleAspectRatio = string.IsNullOrWhiteSpace(videoStream.sample_aspect_ratio) version.SampleAspectRatio = string.IsNullOrWhiteSpace(videoStream.sample_aspect_ratio)
@ -465,7 +468,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
version.Streams.Add(stream); version.Streams.Add(stream);
} }
foreach (FFprobeChapter probedChapter in json.chapters) foreach (FFprobeChapter probedChapter in Optional(json.chapters).Flatten())
{ {
if (double.TryParse( if (double.TryParse(
probedChapter.start_time, probedChapter.start_time,
@ -484,7 +487,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
ChapterId = probedChapter.id, ChapterId = probedChapter.id,
StartTime = TimeSpan.FromSeconds(startTime), StartTime = TimeSpan.FromSeconds(startTime),
EndTime = TimeSpan.FromSeconds(endTime), EndTime = TimeSpan.FromSeconds(endTime),
Title = probedChapter?.tags?.title Title = probedChapter.tags?.title
}; };
version.Chapters.Add(chapter); version.Chapters.Add(chapter);
@ -516,60 +519,60 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
Chapters = new List<MediaChapter>() Chapters = new List<MediaChapter>()
}); });
private VideoScanKind ScanKindFromFieldOrder(string fieldOrder) => private VideoScanKind ScanKindFromFieldOrder(string? fieldOrder) =>
fieldOrder?.ToLowerInvariant() switch fieldOrder?.ToLowerInvariant() switch
{ {
var x when x == "tt" || x == "bb" || x == "tb" || x == "bt" => VideoScanKind.Interlaced, "tt" or "bb" or "tb" or "bt" => VideoScanKind.Interlaced,
"progressive" => VideoScanKind.Progressive, "progressive" => VideoScanKind.Progressive,
_ => VideoScanKind.Unknown _ => VideoScanKind.Unknown
}; };
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
public record FFprobe(FFprobeFormat format, List<FFprobeStream> streams, List<FFprobeChapter> chapters); public record FFprobe(FFprobeFormat? format, List<FFprobeStream>? streams, List<FFprobeChapter>? chapters);
public record FFprobeFormat(string duration, FFprobeTags tags); public record FFprobeFormat(string duration, FFprobeTags? tags);
public record FFprobeDisposition(int @default, int forced, int attached_pic); public record FFprobeDisposition(int @default, int forced, int attached_pic);
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 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 pix_fmt, string? pix_fmt,
string color_range, string? color_range,
string color_space, string? color_space,
string color_transfer, string? color_transfer,
string color_primaries, string? color_primaries,
string field_order, string? field_order,
string r_frame_rate, string? r_frame_rate,
string bits_per_raw_sample, string? bits_per_raw_sample,
FFprobeDisposition disposition, FFprobeDisposition? disposition,
FFprobeTags tags); FFprobeTags? tags);
public record FFprobeChapter( public record FFprobeChapter(
long id, long id,
string start_time, string? start_time,
string end_time, string? end_time,
FFprobeTags tags); FFprobeTags? tags);
public record FFprobeTags( public record FFprobeTags(
string language, string? language,
string title, string? title,
string filename, string? filename,
string mimetype, string? mimetype,
string artist, string? artist,
[property: JsonProperty(PropertyName = "album_artist")] [property: JsonProperty(PropertyName = "album_artist")]
string albumArtist, string? albumArtist,
string album, string? album,
string track, string? track,
string genre, string? genre,
string date) string? date)
{ {
public static readonly FFprobeTags Empty = new(null, null, null, null, null, null, null, null, null, null); public static readonly FFprobeTags Empty = new(null, null, null, null, null, null, null, null, null, null);
} }

17
ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs → ErsatzTV.Scanner/Core/Metadata/LocalSubtitlesProvider.cs

@ -3,9 +3,10 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Scanner.Core.Metadata;
public class LocalSubtitlesProvider : ILocalSubtitlesProvider public class LocalSubtitlesProvider : ILocalSubtitlesProvider
{ {
@ -50,12 +51,12 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider
return false; return false;
} }
Option<Domain.Metadata> maybeMetadata = mediaItem switch Option<ErsatzTV.Core.Domain.Metadata> maybeMetadata = mediaItem switch
{ {
Episode e => e.EpisodeMetadata.OfType<Domain.Metadata>().HeadOrNone(), Episode e => e.EpisodeMetadata.OfType<ErsatzTV.Core.Domain.Metadata>().HeadOrNone(),
Movie m => m.MovieMetadata.OfType<Domain.Metadata>().HeadOrNone(), Movie m => m.MovieMetadata.OfType<ErsatzTV.Core.Domain.Metadata>().HeadOrNone(),
MusicVideo mv => mv.MusicVideoMetadata.OfType<Domain.Metadata>().HeadOrNone(), MusicVideo mv => mv.MusicVideoMetadata.OfType<ErsatzTV.Core.Domain.Metadata>().HeadOrNone(),
OtherVideo ov => ov.OtherVideoMetadata.OfType<Domain.Metadata>().HeadOrNone(), OtherVideo ov => ov.OtherVideoMetadata.OfType<ErsatzTV.Core.Domain.Metadata>().HeadOrNone(),
_ => None _ => None
}; };
@ -67,7 +68,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider
.GetType().Name); .GetType().Name);
} }
foreach (Domain.Metadata metadata in maybeMetadata) foreach (ErsatzTV.Core.Domain.Metadata metadata in maybeMetadata)
{ {
MediaVersion version = mediaItem.GetHeadVersion(); MediaVersion version = mediaItem.GetHeadVersion();
var subtitleStreams = version.Streams var subtitleStreams = version.Streams
@ -96,7 +97,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider
{ {
var result = new List<Subtitle>(); var result = new List<Subtitle>();
string folder = Path.GetDirectoryName(mediaItemPath); string? folder = Path.GetDirectoryName(mediaItemPath);
string withoutExtension = Path.GetFileNameWithoutExtension(mediaItemPath); string withoutExtension = Path.GetFileNameWithoutExtension(mediaItemPath);
foreach (string file in _localFileSystem.ListFiles(folder, $"{withoutExtension}*")) foreach (string file in _localFileSystem.ListFiles(folder, $"{withoutExtension}*"))
{ {

8
ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs → ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -1,14 +1,16 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.MediaServer; using ErsatzTV.Core.Domain.MediaServer;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources; using ErsatzTV.Core.MediaSources;
using MediatR; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Scanner.Core.Metadata;
public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLibrary, TMovie, TEtag> public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLibrary, TMovie, TEtag>
where TConnectionParameters : MediaServerConnectionParameters where TConnectionParameters : MediaServerConnectionParameters
@ -236,7 +238,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
string existingEtag = string.Empty; string existingEtag = string.Empty;
MediaItemState existingState = MediaItemState.Normal; MediaItemState existingState = MediaItemState.Normal;
if (existingMovies.TryGetValue(MediaServerItemId(incoming), out TEtag existingEntry)) if (existingMovies.TryGetValue(MediaServerItemId(incoming), out TEtag? existingEntry))
{ {
existingEtag = existingEntry.Etag; existingEtag = existingEntry.Etag;
existingState = existingEntry.State; existingState = existingEntry.State;

8
ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs → ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs

@ -1,13 +1,15 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.MediaServer; using ErsatzTV.Core.Domain.MediaServer;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources; using ErsatzTV.Core.MediaSources;
using MediatR; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Scanner.Core.Metadata;
public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, TLibrary, TShow, TSeason, TEpisode, public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, TLibrary, TShow, TSeason, TEpisode,
TEtag> TEtag>

2
ErsatzTV.Core/Metadata/MetadataSongTag.cs → ErsatzTV.Scanner/Core/Metadata/MetadataSongTag.cs

@ -1,4 +1,4 @@
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Scanner.Core.Metadata;
public static class MetadataSongTag public static class MetadataSongTag
{ {

1
ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs

@ -10,6 +10,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources; using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.FFmpeg; using ErsatzTV.Scanner.Core.Interfaces.FFmpeg;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Seq = LanguageExt.Seq; using Seq = LanguageExt.Seq;

1
ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs

@ -9,6 +9,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources; using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.FFmpeg; using ErsatzTV.Scanner.Core.Interfaces.FFmpeg;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Metadata; namespace ErsatzTV.Scanner.Core.Metadata;

9
ErsatzTV.Scanner/Core/Metadata/Nfo/ActorNfo.cs

@ -0,0 +1,9 @@
namespace ErsatzTV.Scanner.Core.Metadata.Nfo;
public class ActorNfo
{
public string? Name { get; set; }
public string? Role { get; set; }
public int? Order { get; set; }
public string? Thumb { get; set; }
}

18
ErsatzTV.Scanner/Core/Metadata/Nfo/ArtistNfo.cs

@ -0,0 +1,18 @@
namespace ErsatzTV.Scanner.Core.Metadata.Nfo;
public class ArtistNfo
{
public ArtistNfo()
{
Genres = new List<string>();
Styles = new List<string>();
Moods = new List<string>();
}
public string? Name { get; set; }
public string? Disambiguation { get; set; }
public List<string> Genres { get; }
public List<string> Styles { get; }
public List<string> Moods { get; }
public string? Biography { get; set; }
}

14
ErsatzTV.Core/Metadata/Nfo/ArtistNfoReader.cs → ErsatzTV.Scanner/Core/Metadata/Nfo/ArtistNfoReader.cs

@ -1,11 +1,12 @@
using System.Xml; using System.Xml;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata.Nfo; using ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.IO; using Microsoft.IO;
namespace ErsatzTV.Core.Metadata.Nfo; namespace ErsatzTV.Scanner.Core.Metadata.Nfo;
public class ArtistNfoReader : NfoReader<ArtistNfo>, IArtistNfoReader public class ArtistNfoReader : NfoReader<ArtistNfo>, IArtistNfoReader
{ {
@ -33,7 +34,7 @@ public class ArtistNfoReader : NfoReader<ArtistNfo>, IArtistNfoReader
internal async Task<Either<BaseError, ArtistNfo>> Read(Stream input) internal async Task<Either<BaseError, ArtistNfo>> Read(Stream input)
{ {
ArtistNfo nfo = null; ArtistNfo? nfo = null;
try try
{ {
@ -49,12 +50,7 @@ public class ArtistNfoReader : NfoReader<ArtistNfo>, IArtistNfoReader
switch (reader.Name.ToLowerInvariant()) switch (reader.Name.ToLowerInvariant())
{ {
case "artist": case "artist":
nfo = new ArtistNfo nfo = new ArtistNfo();
{
Genres = new List<string>(),
Styles = new List<string>(),
Moods = new List<string>()
};
break; break;
case "name": case "name":
await ReadStringContent(reader, nfo, (artist, name) => artist.Name = name); await ReadStringContent(reader, nfo, (artist, name) => artist.Name = name);

24
ErsatzTV.Scanner/Core/Metadata/Nfo/EpisodeNfo.cs

@ -0,0 +1,24 @@
namespace ErsatzTV.Scanner.Core.Metadata.Nfo;
public class EpisodeNfo
{
public EpisodeNfo()
{
Actors = new List<ActorNfo>();
Writers = new List<string>();
Directors = new List<string>();
UniqueIds = new List<UniqueIdNfo>();
}
public string? ShowTitle { get; set; }
public string? Title { get; set; }
public int Episode { get; set; }
public int Season { get; set; }
public string? ContentRating { get; set; }
public Option<DateTime> Aired { get; set; }
public string? Plot { get; set; }
public List<ActorNfo> Actors { get; }
public List<string> Writers { get; }
public List<string> Directors { get; }
public List<UniqueIdNfo> UniqueIds { get; }
}

23
ErsatzTV.Core/Metadata/Nfo/EpisodeNfoReader.cs → ErsatzTV.Scanner/Core/Metadata/Nfo/EpisodeNfoReader.cs

@ -1,13 +1,14 @@
using System.Xml; using System.Xml;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata.Nfo; using ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.IO; using Microsoft.IO;
namespace ErsatzTV.Core.Metadata.Nfo; namespace ErsatzTV.Scanner.Core.Metadata.Nfo;
public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader public class EpisodeNfoReader : NfoReader<EpisodeNfo>, IEpisodeNfoReader
{ {
private readonly IClient _client; private readonly IClient _client;
private readonly ILogger<EpisodeNfoReader> _logger; private readonly ILogger<EpisodeNfoReader> _logger;
@ -22,7 +23,7 @@ public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader
_logger = logger; _logger = logger;
} }
public async Task<Either<BaseError, List<TvShowEpisodeNfo>>> ReadFromFile(string fileName) public async Task<Either<BaseError, List<EpisodeNfo>>> ReadFromFile(string fileName)
{ {
// ReSharper disable once ConvertToUsingDeclaration // ReSharper disable once ConvertToUsingDeclaration
await using (Stream s = await SanitizedStreamForFile(fileName)) await using (Stream s = await SanitizedStreamForFile(fileName))
@ -31,14 +32,14 @@ public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader
} }
} }
internal async Task<Either<BaseError, List<TvShowEpisodeNfo>>> Read(Stream input) internal async Task<Either<BaseError, List<EpisodeNfo>>> Read(Stream input)
{ {
var result = new List<TvShowEpisodeNfo>(); var result = new List<EpisodeNfo>();
try try
{ {
using var reader = XmlReader.Create(input, Settings); using var reader = XmlReader.Create(input, Settings);
TvShowEpisodeNfo nfo = null; EpisodeNfo? nfo = null;
while (await reader.ReadAsync()) while (await reader.ReadAsync())
{ {
@ -48,13 +49,7 @@ public class EpisodeNfoReader : NfoReader<TvShowEpisodeNfo>, IEpisodeNfoReader
switch (reader.Name.ToLowerInvariant()) switch (reader.Name.ToLowerInvariant())
{ {
case "episodedetails": case "episodedetails":
nfo = new TvShowEpisodeNfo nfo = new EpisodeNfo();
{
UniqueIds = new List<UniqueIdNfo>(),
Actors = new List<ActorNfo>(),
Writers = new List<string>(),
Directors = new List<string>()
};
// immediately add so we have something to return if we encounter invalid characters // immediately add so we have something to return if we encounter invalid characters
result.Add(nfo); result.Add(nfo);
break; break;

31
ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfo.cs

@ -0,0 +1,31 @@
namespace ErsatzTV.Scanner.Core.Metadata.Nfo;
public class MovieNfo
{
public MovieNfo()
{
Genres = new List<string>();
Tags = new List<string>();
Studios = new List<string>();
Actors = new List<ActorNfo>();
Writers = new List<string>();
Directors = new List<string>();
UniqueIds = new List<UniqueIdNfo>();
}
public string? Title { get; set; }
public string? SortTitle { get; set; }
public string? Outline { get; set; }
public int Year { get; set; }
public string? ContentRating { get; set; }
public Option<DateTime> Premiered { get; set; }
public string? Plot { get; set; }
// public string? Tagline { get; set; }
public List<string> Genres { get; }
public List<string> Tags { get; }
public List<string> Studios { get; }
public List<ActorNfo> Actors { get; }
public List<string> Writers { get; }
public List<string> Directors { get; }
public List<UniqueIdNfo> UniqueIds { get; }
}

18
ErsatzTV.Core/Metadata/Nfo/MovieNfoReader.cs → ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfoReader.cs

@ -1,11 +1,12 @@
using System.Xml; using System.Xml;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata.Nfo; using ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.IO; using Microsoft.IO;
namespace ErsatzTV.Core.Metadata.Nfo; namespace ErsatzTV.Scanner.Core.Metadata.Nfo;
public class MovieNfoReader : NfoReader<MovieNfo>, IMovieNfoReader public class MovieNfoReader : NfoReader<MovieNfo>, IMovieNfoReader
{ {
@ -33,7 +34,7 @@ public class MovieNfoReader : NfoReader<MovieNfo>, IMovieNfoReader
internal async Task<Either<BaseError, MovieNfo>> Read(Stream input) internal async Task<Either<BaseError, MovieNfo>> Read(Stream input)
{ {
MovieNfo nfo = null; MovieNfo? nfo = null;
try try
{ {
@ -49,16 +50,7 @@ public class MovieNfoReader : NfoReader<MovieNfo>, IMovieNfoReader
switch (reader.Name.ToLowerInvariant()) switch (reader.Name.ToLowerInvariant())
{ {
case "movie": case "movie":
nfo = new MovieNfo nfo = new MovieNfo();
{
Genres = new List<string>(),
Tags = new List<string>(),
Studios = new List<string>(),
Actors = new List<ActorNfo>(),
Writers = new List<string>(),
Directors = new List<string>(),
UniqueIds = new List<UniqueIdNfo>()
};
break; break;
case "title": case "title":
await ReadStringContent(reader, nfo, (movie, title) => movie.Title = title); await ReadStringContent(reader, nfo, (movie, title) => movie.Title = title);

23
ErsatzTV.Scanner/Core/Metadata/Nfo/MusicVideoNfo.cs

@ -0,0 +1,23 @@
namespace ErsatzTV.Scanner.Core.Metadata.Nfo;
public class MusicVideoNfo
{
public MusicVideoNfo()
{
Artists = new List<string>();
Genres = new List<string>();
Tags = new List<string>();
Studios = new List<string>();
}
public List<string> Artists { get; }
public string? Title { get; set; }
public string? Album { get; set; }
public string? Plot { get; set; }
public int Track { get; set; }
public Option<DateTime> Aired { get; set; }
public int Year { get; set; }
public List<string> Genres { get; }
public List<string> Tags { get; }
public List<string> Studios { get; }
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save