Browse Source

generate song video (#497)

* use blurred cover art as song background

* use channel watermark when cover art is unavailable

* add drawtext to song filter

* cleanup

* force song cover art as png

* fix songs on windows and qsv
pull/499/head
Jason Dove 5 years ago committed by GitHub
parent
commit
ed3f1b1dad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 101
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  2. 18
      ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs
  3. 48
      ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs
  4. 3
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  5. 2
      ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs
  6. 3
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  7. 6
      ErsatzTV.Core/Domain/MediaItem/CoverArtMediaVersion.cs
  8. 6
      ErsatzTV.Core/Domain/MediaItem/FallbackMediaVersion.cs
  9. 101
      ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs
  10. 33
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  11. 79
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  12. 19
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  13. 6
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  14. 7
      ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs
  15. 3
      ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs
  16. 68
      ErsatzTV.Core/Metadata/SongFolderScanner.cs
  17. 9
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  18. 2
      ErsatzTV.Infrastructure/Data/Repositories/ArtworkRepository.cs
  19. 5
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  20. 1
      ErsatzTV/ErsatzTV.csproj
  21. 1
      ErsatzTV/Pages/WatermarkEditor.razor
  22. 4
      ErsatzTV/Pages/Watermarks.razor
  23. BIN
      ErsatzTV/Resources/OPTIKabel-Heavy.otf
  24. 1
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

101
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@ -11,6 +13,7 @@ using ErsatzTV.Core.Errors; @@ -11,6 +13,7 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
@ -37,6 +40,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -37,6 +40,7 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo;
private readonly IImageCache _imageCache;
public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
@ -48,7 +52,8 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -48,7 +52,8 @@ namespace ErsatzTV.Application.Streaming.Queries
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo)
IRuntimeInfo runtimeInfo,
IImageCache imageCache)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
@ -60,6 +65,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -60,6 +65,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_runtimeInfo = runtimeInfo;
_imageCache = imageCache;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@ -97,10 +103,13 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -97,10 +103,13 @@ namespace ErsatzTV.Application.Streaming.Queries
.ThenInclude(ov => ov.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.ThenInclude(ov => ov.MediaFiles)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.ThenInclude(ov => ov.Streams)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.ThenInclude(sm => sm.Artwork)
.ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath);
@ -113,6 +122,8 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -113,6 +122,8 @@ namespace ErsatzTV.Application.Streaming.Queries
return await maybePlayoutItem.Match(
async playoutItemWithPath =>
{
Option<string> drawtextFile = None;
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
string videoPath = playoutItemWithPath.Path;
@ -121,25 +132,80 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -121,25 +132,80 @@ namespace ErsatzTV.Application.Streaming.Queries
string audioPath = playoutItemWithPath.Path;
MediaVersion audioVersion = version;
if (playoutItemWithPath.PlayoutItem.MediaItem is Song)
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
{
// find filler to loop as video for song
Either<BaseError, PlayoutItemWithPath> fallbackFiller =
await CheckForFallbackFiller(dbContext, channel, now);
videoVersion = new FallbackMediaVersion
{
Id = -1,
Chapters = new List<MediaChapter>(),
Width = 301,
Height = 162,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
// fail if we can't find filler
if (fallbackFiller.IsLeft)
// use ETV logo by default
string artworkPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "ErsatzTV.png");
// use thumbnail (cover art) if present
foreach (SongMetadata metadata in song.SongMetadata)
{
return Left<BaseError, PlayoutItemProcessModel>(
BaseError.New("Unable to locate fallback filler for song"));
string fileName = Path.GetTempFileName();
drawtextFile = fileName;
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(metadata.Artist))
{
sb.AppendLine(metadata.Artist);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.AppendLine($"\"{metadata.Title}\"");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.AppendLine(metadata.Album);
}
await File.WriteAllTextAsync(fileName, sb.ToString());
foreach (Artwork artwork in Optional(
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
{
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
artworkPath = customPath;
// signal that we want to use cover art as watermark
videoVersion = new CoverArtMediaVersion
{
Chapters = new List<MediaChapter>(),
// always stretch cover art
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
}
}
foreach (PlayoutItemWithPath filler in fallbackFiller.RightToSeq())
videoPath = artworkPath;
videoVersion.MediaFiles = new List<MediaFile>
{
videoPath = filler.Path;
videoVersion = filler.PlayoutItem.MediaItem.GetHeadVersion();
}
new() { Path = videoPath }
};
}
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
@ -169,7 +235,8 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -169,7 +235,8 @@ namespace ErsatzTV.Application.Streaming.Queries
request.HlsRealtime,
playoutItemWithPath.PlayoutItem.FillerKind,
playoutItemWithPath.PlayoutItem.InPoint,
playoutItemWithPath.PlayoutItem.OutPoint);
playoutItemWithPath.PlayoutItem.OutPoint,
drawtextFile);
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);

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

@ -19,7 +19,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -19,7 +19,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var builder = new FFmpegComplexFilterBuilder();
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
Option<FFmpegComplexFilter> result = builder.Build("", 0, 0, 0, 1, false);
result.IsNone.Should().BeTrue();
}
@ -31,7 +31,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -31,7 +31,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
Option<FFmpegComplexFilter> result = builder.Build("", 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -52,7 +52,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -52,7 +52,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
Option<FFmpegComplexFilter> result = builder.Build("", 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -72,7 +72,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -72,7 +72,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
.WithAlignedAudio(duration)
.WithDeinterlace(true);
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
Option<FFmpegComplexFilter> result = builder.Build("", 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -123,7 +123,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -123,7 +123,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
Option<FFmpegComplexFilter> result = builder.Build("", 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -279,7 +279,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -279,7 +279,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
.WithDeinterlace(deinterlace)
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
Option<FFmpegComplexFilter> result = builder.Build("", 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -350,7 +350,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -350,7 +350,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
Option<FFmpegComplexFilter> result = builder.Build("", 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -421,7 +421,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -421,7 +421,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
Option<FFmpegComplexFilter> result = builder.Build("", 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
@ -543,7 +543,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -543,7 +543,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build(0, 0, 0, 1);
Option<FFmpegComplexFilter> result = builder.Build("", 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(

48
ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs

@ -12,7 +12,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -12,7 +12,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
@ -26,13 +26,13 @@ live001137.ts @@ -26,13 +26,13 @@ live001137.ts
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
@ -49,14 +49,14 @@ live001138.ts @@ -49,14 +49,14 @@ live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
");
"));
}
[Test]
public void HlsPlaylistFilter_ShouldLimitSegments()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
@ -70,13 +70,13 @@ live001137.ts @@ -70,13 +70,13 @@ live001137.ts
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
@ -90,14 +90,14 @@ live001137.ts @@ -90,14 +90,14 @@ live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts
");
"));
}
[Test]
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
@ -111,7 +111,7 @@ live001137.ts @@ -111,7 +111,7 @@ live001137.ts
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(
start,
@ -122,7 +122,7 @@ live001139.ts".Split(Environment.NewLine); @@ -122,7 +122,7 @@ live001139.ts".Split(Environment.NewLine);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
@ -140,14 +140,14 @@ live001138.ts @@ -140,14 +140,14 @@ live001138.ts
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
#EXT-X-DISCONTINUITY
");
"));
}
[Test]
public void HlsPlaylistFilter_ShouldFilterOldSegments()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
@ -161,13 +161,13 @@ live001137.ts @@ -161,13 +161,13 @@ live001137.ts
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
result.Playlist.Should().Be(
result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
@ -178,14 +178,14 @@ live001139.ts".Split(Environment.NewLine); @@ -178,14 +178,14 @@ live001139.ts".Split(Environment.NewLine);
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
");
"));
}
[Test]
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
@ -200,13 +200,13 @@ live001137.ts @@ -200,13 +200,13 @@ live001137.ts
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
result.Playlist.Should().Be(
result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
@ -217,7 +217,15 @@ live001139.ts".Split(Environment.NewLine); @@ -217,7 +217,15 @@ live001139.ts".Split(Environment.NewLine);
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
");
"));
}
private static string NormalizeLineEndings(string str)
{
return str
.Replace("\r\n", "\n")
.Replace("\r", "\n")
.Replace("\n", Environment.NewLine);
}
}
}

3
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs

@ -196,7 +196,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -196,7 +196,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
FillerKind.None,
TimeSpan.Zero,
TimeSpan.FromSeconds(5));
TimeSpan.FromSeconds(5),
None);
process.StartInfo.RedirectStandardError = true;

2
ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs

@ -6,6 +6,7 @@ using System.Runtime.InteropServices; @@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -601,6 +602,7 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -601,6 +602,7 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
new Mock<IMediator>().Object,
null,
new Mock<ILogger<MovieFolderScanner>>().Object
);
}

3
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -45,7 +45,6 @@ @@ -45,7 +45,6 @@
public enum ChannelWatermarkImageSource
{
Custom = 0,
ChannelLogo = 1,
CoverArt = 2
ChannelLogo = 1
}
}

6
ErsatzTV.Core/Domain/MediaItem/CoverArtMediaVersion.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class CoverArtMediaVersion : MediaVersion
{
}
}

6
ErsatzTV.Core/Domain/MediaItem/FallbackMediaVersion.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class FallbackMediaVersion : MediaVersion
{
}
}

101
ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs

@ -1,7 +1,9 @@ @@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@ -24,6 +26,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -24,6 +26,7 @@ namespace ErsatzTV.Core.FFmpeg
private Option<int> _watermarkIndex;
private string _pixelFormat;
private string _videoEncoder;
private Option<string> _drawtext;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@ -91,6 +94,38 @@ namespace ErsatzTV.Core.FFmpeg @@ -91,6 +94,38 @@ namespace ErsatzTV.Core.FFmpeg
_watermarkIndex = watermarkIndex;
return this;
}
public FFmpegComplexFilterBuilder WithDrawtextFile(
MediaVersion videoVersion,
Option<string> drawtextFile)
{
foreach (string file in drawtextFile)
{
string effectiveFile = file;
if (videoVersion is FallbackMediaVersion or CoverArtMediaVersion)
{
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "OPTIKabel-Heavy.otf");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
fontPath = fontPath
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
effectiveFile = effectiveFile
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
}
// TODO: calculate by percent
_drawtext =
$"drawtext=fontfile={fontPath}:textfile={effectiveFile}:x=50:y=H-175:fontsize=36:fontcolor=white";
}
}
return this;
}
public FFmpegComplexFilterBuilder WithVideoEncoder(string videoEncoder)
{
@ -98,19 +133,19 @@ namespace ErsatzTV.Core.FFmpeg @@ -98,19 +133,19 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public Option<FFmpegComplexFilter> Build(int videoInput, int videoStreamIndex, int audioInput, Option<int> audioStreamIndex)
public Option<FFmpegComplexFilter> Build(string videoPath, int videoInput, int videoStreamIndex, int audioInput, Option<int> audioStreamIndex, bool isSong)
{
var complexFilter = new StringBuilder();
var videoLabel = $"{videoInput}:{videoStreamIndex}";
var videoLabel = $"{videoInput}:{(isSong ? "v" : videoStreamIndex.ToString())}";
string audioLabel = audioStreamIndex.Match(index => $"{audioInput}:{index}", () => "0:a");
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch
{
HardwareAccelerationKind.Vaapi => _inputCodec != "mpeg4",
HardwareAccelerationKind.Nvenc => true,
HardwareAccelerationKind.Qsv => true,
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4",
HardwareAccelerationKind.Nvenc => !isSong,
HardwareAccelerationKind.Qsv => !isSong,
_ => false
};
@ -118,7 +153,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -118,7 +153,7 @@ namespace ErsatzTV.Core.FFmpeg
var videoFilterQueue = new List<string>();
string watermarkPreprocess = string.Empty;
string watermarkOverlay = string.Empty;
if (_normalizeLoudness)
{
audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11");
@ -133,9 +168,31 @@ namespace ErsatzTV.Core.FFmpeg @@ -133,9 +168,31 @@ namespace ErsatzTV.Core.FFmpeg
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome);
if (usesHardwareFilters)
if (isSong)
{
videoFilterQueue.Add("hwupload");
switch (acceleration)
{
case HardwareAccelerationKind.Qsv:
videoFilterQueue.Add("format=nv12");
break;
default:
videoFilterQueue.Add("format=yuv420p");
break;
}
}
switch (usesHardwareFilters, false, acceleration)
{
case (true, false, HardwareAccelerationKind.Nvenc):
videoFilterQueue.Add("hwupload_cuda");
break;
case (true, false, HardwareAccelerationKind.Qsv):
videoFilterQueue.Add("hwupload=extra_hw_frames=64");
break;
case (true, false, _):
videoFilterQueue.Add("hwupload");
break;
}
if (_deinterlace)
@ -176,6 +233,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -176,6 +233,7 @@ namespace ErsatzTV.Core.FFmpeg
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" =>
$"hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc when isSong => $"scale_cuda={size.Width}:{size.Height}:format=yuv420p",
HardwareAccelerationKind.Nvenc => $"scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
@ -200,6 +258,8 @@ namespace ErsatzTV.Core.FFmpeg @@ -200,6 +258,8 @@ namespace ErsatzTV.Core.FFmpeg
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
"format=p010le,format=nv12",
HardwareAccelerationKind.Qsv when isSong => "format=nv12,format=yuv420p",
_ when isSong => "format=yuv420p",
_ => "format=nv12"
};
videoFilterQueue.Add(format);
@ -210,6 +270,11 @@ namespace ErsatzTV.Core.FFmpeg @@ -210,6 +270,11 @@ namespace ErsatzTV.Core.FFmpeg
videoFilterQueue.Add("setsar=1");
}
if (isSong)
{
videoFilterQueue.Add("boxblur=75,fps=24");
}
foreach (ChannelWatermark watermark in _watermark)
{
string enable = watermark.Mode == ChannelWatermarkMode.Intermittent
@ -255,6 +320,11 @@ namespace ErsatzTV.Core.FFmpeg @@ -255,6 +320,11 @@ namespace ErsatzTV.Core.FFmpeg
}
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
foreach (string drawtext in _drawtext)
{
videoFilterQueue.Add(drawtext);
}
string outputPixelFormat = null;
@ -338,10 +408,21 @@ namespace ErsatzTV.Core.FFmpeg @@ -338,10 +408,21 @@ namespace ErsatzTV.Core.FFmpeg
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
{
complexFilter.Append(",hwupload");
switch (isSong, acceleration)
{
case (true, HardwareAccelerationKind.Nvenc):
complexFilter.Append(",hwupload_cuda");
break;
case (_, HardwareAccelerationKind.Qsv):
complexFilter.Append(",format=yuv420p,hwupload=extra_hw_frames=64");
break;
default:
complexFilter.Append(",hwupload");
break;
}
}
}
videoLabel = "[v]";
complexFilter.Append(videoLabel);
}

33
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -220,6 +220,17 @@ namespace ErsatzTV.Core.FFmpeg @@ -220,6 +220,17 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithDrawtextFile(
MediaVersion videoVersion,
Option<string> drawtextFile)
{
_complexFilterBuilder = _complexFilterBuilder.WithDrawtextFile(
videoVersion,
drawtextFile);
return this;
}
public FFmpegProcessBuilder WithInputCodec(
Option<TimeSpan> maybeStart,
bool loop,
@ -236,7 +247,10 @@ namespace ErsatzTV.Core.FFmpeg @@ -236,7 +247,10 @@ namespace ErsatzTV.Core.FFmpeg
}
else
{
WithInfiniteLoop();
_noAutoScale = true;
_arguments.Add("-loop");
_arguments.Add("1");
}
if (!string.IsNullOrWhiteSpace(decoder))
@ -363,7 +377,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -363,7 +377,7 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add($"{format}");
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
{
const int SEGMENT_SECONDS = 4;
@ -515,6 +529,17 @@ namespace ErsatzTV.Core.FFmpeg @@ -515,6 +529,17 @@ namespace ErsatzTV.Core.FFmpeg
_complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace);
return this;
}
public FFmpegProcessBuilder WithOutputFormat(string format, string output)
{
_arguments.Add("-f");
_arguments.Add(format);
_arguments.Add("-y");
_arguments.Add(output);
return this;
}
public FFmpegProcessBuilder WithFilterComplex(
MediaStream videoStream,
@ -539,10 +564,12 @@ namespace ErsatzTV.Core.FFmpeg @@ -539,10 +564,12 @@ namespace ErsatzTV.Core.FFmpeg
var audioLabel = $"{audioIndex}:{maybeIndex.Match(i => i.ToString(), () => "a")}";
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(
videoPath,
videoIndex,
videoStreamIndex,
audioIndex,
maybeIndex);
maybeIndex,
videoPath != audioPath);
maybeFilter.IfSome(
filter =>

79
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -48,7 +48,8 @@ namespace ErsatzTV.Core.FFmpeg @@ -48,7 +48,8 @@ namespace ErsatzTV.Core.FFmpeg
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
TimeSpan outPoint)
TimeSpan outPoint,
Option<string> drawtextFile)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
@ -65,7 +66,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -65,7 +66,7 @@ namespace ErsatzTV.Core.FFmpeg
outPoint);
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, audioVersion);
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
@ -86,6 +87,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -86,6 +87,7 @@ namespace ErsatzTV.Core.FFmpeg
videoStream.Codec,
videoStream.PixelFormat)
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
.WithDrawtextFile(videoVersion, drawtextFile)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None)
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
@ -222,17 +224,49 @@ namespace ErsatzTV.Core.FFmpeg @@ -222,17 +224,49 @@ namespace ErsatzTV.Core.FFmpeg
.Build();
}
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithInput(inputFile)
.WithOutputFormat("apng", outputFile)
.Build();
}
private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) =>
displaySize.Width != target.Width || displaySize.Height != target.Height;
private async Task<WatermarkOptions> GetWatermarkOptions(
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion audioVersion)
MediaVersion videoVersion)
{
Option<ChannelWatermark> watermarkOverride = videoVersion is FallbackMediaVersion or CoverArtMediaVersion
? new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
HorizontalMarginPercent = 3,
VerticalMarginPercent = 5,
Location = ChannelWatermarkLocation.BottomRight,
Size = ChannelWatermarkSize.Scaled,
WidthPercent = 25,
Opacity = 100
}
: None;
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo)
{
if (videoVersion is CoverArtMediaVersion)
{
return new WatermarkOptions(
watermarkOverride,
videoVersion.MediaFiles.Head().Path,
0,
false);
}
// check for channel watermark
if (channel.Watermark != null)
{
@ -244,7 +278,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -244,7 +278,7 @@ namespace ErsatzTV.Core.FFmpeg
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
channel.Watermark,
await watermarkOverride.IfNoneAsync(channel.Watermark),
customPath,
None,
await _imageCache.IsAnimated(customPath));
@ -254,28 +288,12 @@ namespace ErsatzTV.Core.FFmpeg @@ -254,28 +288,12 @@ namespace ErsatzTV.Core.FFmpeg
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(
channel.Watermark,
await watermarkOverride.IfNoneAsync(channel.Watermark),
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false)));
case ChannelWatermarkImageSource.CoverArt:
Option<MediaStream> maybeAttachedPicStream =
Optional(audioVersion.Streams.Find(s => s.AttachedPic));
// only return a watermark if there is an attachment
// to allow falling back on a global watermark
foreach (MediaStream attachedPicStream in maybeAttachedPicStream)
{
return new WatermarkOptions(
channel.Watermark,
audioVersion.MediaFiles.Head().Path,
attachedPicStream.Index,
false);
}
break;
default:
throw new NotSupportedException("Unsupported watermark image source");
}
@ -292,7 +310,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -292,7 +310,7 @@ namespace ErsatzTV.Core.FFmpeg
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(
watermark,
await watermarkOverride.IfNoneAsync(watermark),
customPath,
None,
await _imageCache.IsAnimated(customPath));
@ -302,27 +320,12 @@ namespace ErsatzTV.Core.FFmpeg @@ -302,27 +320,12 @@ namespace ErsatzTV.Core.FFmpeg
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(
watermark,
await watermarkOverride.IfNoneAsync(watermark),
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false)));
case ChannelWatermarkImageSource.CoverArt:
Option<MediaStream> maybeAttachedPicStream =
Optional(audioVersion.Streams.Find(s => s.AttachedPic));
// only return a watermark if there is an attachment
foreach (MediaStream attachedPicStream in maybeAttachedPicStream)
{
return new WatermarkOptions(
channel.Watermark,
audioVersion.MediaFiles.Head().Path,
attachedPicStream.Index,
false);
}
break;
default:
throw new NotSupportedException("Unsupported watermark image source");
}

19
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -1,10 +1,12 @@ @@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -47,6 +49,7 @@ namespace ErsatzTV.Core.Metadata @@ -47,6 +49,7 @@ namespace ErsatzTV.Core.Metadata
.ToList();
private readonly IImageCache _imageCache;
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
@ -58,12 +61,14 @@ namespace ErsatzTV.Core.Metadata @@ -58,12 +61,14 @@ namespace ErsatzTV.Core.Metadata
ILocalStatisticsProvider localStatisticsProvider,
IMetadataRepository metadataRepository,
IImageCache imageCache,
FFmpegProcessService ffmpegProcessService,
ILogger logger)
{
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_metadataRepository = metadataRepository;
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
_logger = logger;
}
@ -107,7 +112,7 @@ namespace ErsatzTV.Core.Metadata @@ -107,7 +112,7 @@ namespace ErsatzTV.Core.Metadata
}
}
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind)
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind, Option<string> ffmpegPath)
{
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
@ -124,6 +129,18 @@ namespace ErsatzTV.Core.Metadata @@ -124,6 +129,18 @@ namespace ErsatzTV.Core.Metadata
try
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
// if ffmpeg path is passed, we want to convert to png
foreach (string path in ffmpegPath)
{
string tempName = Path.GetTempFileName();
using Process process = _ffmpegProcessService.ConvertToPng(path, artworkFile, tempName);
process.Start();
await process.WaitForExitAsync();
artworkFile = tempName;
}
Either<BaseError, string> maybeCacheName =
await _imageCache.CopyArtworkToCache(artworkFile, artworkKind);

6
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -5,6 +5,7 @@ using System.Linq; @@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -40,8 +41,9 @@ namespace ErsatzTV.Core.Metadata @@ -40,8 +41,9 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediator mediator,
FFmpegProcessService ffmpegProcessService,
ILogger<MovieFolderScanner> logger)
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, ffmpegProcessService, logger)
{
_localFileSystem = localFileSystem;
_movieRepository = movieRepository;
@ -229,7 +231,7 @@ namespace ErsatzTV.Core.Metadata @@ -229,7 +231,7 @@ namespace ErsatzTV.Core.Metadata
async posterFile =>
{
MovieMetadata metadata = movie.MovieMetadata.Head();
await RefreshArtwork(posterFile, metadata, artworkKind);
await RefreshArtwork(posterFile, metadata, artworkKind, None);
});
return result;

7
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

@ -5,6 +5,7 @@ using System.Linq; @@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -41,11 +42,13 @@ namespace ErsatzTV.Core.Metadata @@ -41,11 +42,13 @@ namespace ErsatzTV.Core.Metadata
IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository,
IMediator mediator,
FFmpegProcessService ffmpegProcessService,
ILogger<MusicVideoFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
ffmpegProcessService,
logger)
{
_localFileSystem = localFileSystem;
@ -217,7 +220,7 @@ namespace ErsatzTV.Core.Metadata @@ -217,7 +220,7 @@ namespace ErsatzTV.Core.Metadata
async artworkFile =>
{
ArtistMetadata metadata = artist.ArtistMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind);
await RefreshArtwork(artworkFile, metadata, artworkKind, None);
});
return result;
@ -380,7 +383,7 @@ namespace ErsatzTV.Core.Metadata @@ -380,7 +383,7 @@ namespace ErsatzTV.Core.Metadata
async thumbnailFile =>
{
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail);
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None);
});
return result;

3
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs

@ -5,6 +5,7 @@ using System.Linq; @@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -39,11 +40,13 @@ namespace ErsatzTV.Core.Metadata @@ -39,11 +40,13 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
IOtherVideoRepository otherVideoRepository,
ILibraryRepository libraryRepository,
FFmpegProcessService ffmpegProcessService,
ILogger<OtherVideoFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
ffmpegProcessService,
logger)
{
_localFileSystem = localFileSystem;

68
ErsatzTV.Core/Metadata/SongFolderScanner.cs

@ -6,6 +6,7 @@ using System.Threading.Tasks; @@ -6,6 +6,7 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -40,11 +41,13 @@ namespace ErsatzTV.Core.Metadata @@ -40,11 +41,13 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
ISongRepository songRepository,
ILibraryRepository libraryRepository,
FFmpegProcessService ffmpegProcessService,
ILogger<SongFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
ffmpegProcessService,
logger)
{
_localFileSystem = localFileSystem;
@ -124,8 +127,8 @@ namespace ErsatzTV.Core.Metadata @@ -124,8 +127,8 @@ namespace ErsatzTV.Core.Metadata
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath));
// .BindT(video => UpdateThumbnail(video, ffprobePath, ffmpegPath));
.BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(video => UpdateThumbnail(video, ffprobePath, ffmpegPath));
await maybeSong.Match(
async result =>
@ -201,27 +204,44 @@ namespace ErsatzTV.Core.Metadata @@ -201,27 +204,44 @@ namespace ErsatzTV.Core.Metadata
}
}
// private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail(
// MediaItemScanResult<Song> result,
// string ffprobePath,
// string ffmpegPath)
// {
// try
// {
// Song song = result.Item;
// await LocateThumbnail(song).IfSomeAsync(
// async thumbnailFile =>
// {
// SongMetadata metadata = song.SongMetadata.Head();
// await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail);
// });
//
// return result;
// }
// catch (Exception ex)
// {
// return BaseError.New(ex.ToString());
// }
// }
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail(
MediaItemScanResult<Song> result,
string ffprobePath,
string ffmpegPath)
{
try
{
Song song = result.Item;
await LocateThumbnail(song).Match(
async thumbnailFile =>
{
SongMetadata metadata = song.SongMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, ffmpegPath);
},
() => Task.CompletedTask); // TODO: check for embedded artwork
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateThumbnail(Song song)
{
string path = song.MediaVersions.Head().MediaFiles.Head().Path;
Option<DirectoryInfo> parent = Optional(Directory.GetParent(path));
return parent.Map(
di =>
{
string coverPath = Path.Combine(di.FullName, "cover.jpg");
return ImageFileExtensions
.Map(ext => Path.ChangeExtension(coverPath, ext))
.Filter(f => _localFileSystem.FileExists(f))
.HeadOrNone();
}).Flatten();
}
}
}

9
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -5,6 +5,7 @@ using System.Linq; @@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -40,11 +41,13 @@ namespace ErsatzTV.Core.Metadata @@ -40,11 +41,13 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediator mediator,
FFmpegProcessService ffmpegProcessService,
ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
ffmpegProcessService,
logger)
{
_localFileSystem = localFileSystem;
@ -362,7 +365,7 @@ namespace ErsatzTV.Core.Metadata @@ -362,7 +365,7 @@ namespace ErsatzTV.Core.Metadata
async artworkFile =>
{
ShowMetadata metadata = show.ShowMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind);
await RefreshArtwork(artworkFile, metadata, artworkKind, None);
});
return result;
@ -381,7 +384,7 @@ namespace ErsatzTV.Core.Metadata @@ -381,7 +384,7 @@ namespace ErsatzTV.Core.Metadata
async posterFile =>
{
SeasonMetadata metadata = season.SeasonMetadata.Head();
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster);
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None);
});
return season;
@ -401,7 +404,7 @@ namespace ErsatzTV.Core.Metadata @@ -401,7 +404,7 @@ namespace ErsatzTV.Core.Metadata
{
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail, None);
}
});

2
ErsatzTV.Infrastructure/Data/Repositories/ArtworkRepository.cs

@ -21,7 +21,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -21,7 +21,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE A.ArtistMetadataId IS NULL AND A.EpisodeMetadataId IS NULL
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL
AND A.ChannelId IS NULL
AND A.SongMetadataId IS NULL AND A.ChannelId IS NULL
AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)")
.Map(result => result.ToList());

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

@ -248,6 +248,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -248,6 +248,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
SongMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, SongMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
_ => Task.FromResult(Unit.Default)
};
}

1
ErsatzTV/ErsatzTV.csproj

@ -53,6 +53,7 @@ @@ -53,6 +53,7 @@
<EmbeddedResource Include="Resources\background.png" />
<EmbeddedResource Include="Resources\ErsatzTV.png" />
<EmbeddedResource Include="Resources\Roboto-Regular.ttf" />
<EmbeddedResource Include="Resources\OPTIKabel-Heavy.otf" />
<EmbeddedResource Include="Resources\ISO-639-2_utf-8.txt" />
</ItemGroup>

1
ErsatzTV/Pages/WatermarkEditor.razor

@ -31,7 +31,6 @@ @@ -31,7 +31,6 @@
Disabled="@(_model.Mode == ChannelWatermarkMode.None)">
<MudSelectItem Value="@(ChannelWatermarkImageSource.Custom)">Custom</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkImageSource.ChannelLogo)">Channel Logo</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkImageSource.CoverArt)">Cover Art</MudSelectItem>
</MudSelect>
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6">

4
ErsatzTV/Pages/Watermarks.razor

@ -38,10 +38,6 @@ @@ -38,10 +38,6 @@
{
<MudText>[channel logo]</MudText>
}
else if (context.ImageSource == ChannelWatermarkImageSource.CoverArt)
{
<MudText>[cover art]</MudText>
}
</MudTd>
<MudTd DataLabel="Mode">
@context.Mode

BIN
ErsatzTV/Resources/OPTIKabel-Heavy.otf

Binary file not shown.

1
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -21,6 +21,7 @@ namespace ErsatzTV.Services.RunOnce @@ -21,6 +21,7 @@ namespace ErsatzTV.Services.RunOnce
await ExtractResource(assembly, "background.png", cancellationToken);
await ExtractResource(assembly, "ErsatzTV.png", cancellationToken);
await ExtractResource(assembly, "Roboto-Regular.ttf", cancellationToken);
await ExtractResource(assembly, "OPTIKabel-Heavy.otf", cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;

Loading…
Cancel
Save