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 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@ -11,6 +13,7 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
@ -37,6 +40,7 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo; private readonly IRuntimeInfo _runtimeInfo;
private readonly IImageCache _imageCache;
public GetPlayoutItemProcessByChannelNumberHandler( public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
@ -48,7 +52,8 @@ namespace ErsatzTV.Application.Streaming.Queries
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
IArtistRepository artistRepository, IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
IImageCache imageCache)
: base(dbContextFactory) : base(dbContextFactory)
{ {
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
@ -60,6 +65,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_artistRepository = artistRepository; _artistRepository = artistRepository;
_runtimeInfo = runtimeInfo; _runtimeInfo = runtimeInfo;
_imageCache = imageCache;
} }
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@ -97,10 +103,13 @@ namespace ErsatzTV.Application.Streaming.Queries
.ThenInclude(ov => ov.Streams) .ThenInclude(ov => ov.Streams)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions) .ThenInclude(mi => (mi as Song).MediaVersions)
.ThenInclude(ov => ov.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions) .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) .ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem())) .Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath); .BindT(ValidatePlayoutItemPath);
@ -113,6 +122,8 @@ namespace ErsatzTV.Application.Streaming.Queries
return await maybePlayoutItem.Match( return await maybePlayoutItem.Match(
async playoutItemWithPath => async playoutItemWithPath =>
{ {
Option<string> drawtextFile = None;
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion(); MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
string videoPath = playoutItemWithPath.Path; string videoPath = playoutItemWithPath.Path;
@ -121,25 +132,80 @@ namespace ErsatzTV.Application.Streaming.Queries
string audioPath = playoutItemWithPath.Path; string audioPath = playoutItemWithPath.Path;
MediaVersion audioVersion = version; MediaVersion audioVersion = version;
if (playoutItemWithPath.PlayoutItem.MediaItem is Song) if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
{ {
// find filler to loop as video for song videoVersion = new FallbackMediaVersion
{
Either<BaseError, PlayoutItemWithPath> fallbackFiller = Id = -1,
await CheckForFallbackFiller(dbContext, channel, now); 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 // use ETV logo by default
if (fallbackFiller.IsLeft) string artworkPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "ErsatzTV.png");
// use thumbnail (cover art) if present
foreach (SongMetadata metadata in song.SongMetadata)
{ {
return Left<BaseError, PlayoutItemProcessModel>( string fileName = Path.GetTempFileName();
BaseError.New("Unable to locate fallback filler for song")); 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; new() { Path = videoPath }
videoVersion = filler.PlayoutItem.MediaItem.GetHeadVersion(); };
}
} }
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
@ -169,7 +235,8 @@ namespace ErsatzTV.Application.Streaming.Queries
request.HlsRealtime, request.HlsRealtime,
playoutItemWithPath.PlayoutItem.FillerKind, playoutItemWithPath.PlayoutItem.FillerKind,
playoutItemWithPath.PlayoutItem.InPoint, playoutItemWithPath.PlayoutItem.InPoint,
playoutItemWithPath.PlayoutItem.OutPoint); playoutItemWithPath.PlayoutItem.OutPoint,
drawtextFile);
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset); var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);

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

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

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

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

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

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

3
ErsatzTV.Core/Domain/ChannelWatermark.cs

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

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

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

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

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

101
ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs

@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
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;
@ -24,6 +26,7 @@ namespace ErsatzTV.Core.FFmpeg
private Option<int> _watermarkIndex; private Option<int> _watermarkIndex;
private string _pixelFormat; private string _pixelFormat;
private string _videoEncoder; private string _videoEncoder;
private Option<string> _drawtext;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind) public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{ {
@ -91,6 +94,38 @@ namespace ErsatzTV.Core.FFmpeg
_watermarkIndex = watermarkIndex; _watermarkIndex = watermarkIndex;
return this; 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) public FFmpegComplexFilterBuilder WithVideoEncoder(string videoEncoder)
{ {
@ -98,19 +133,19 @@ namespace ErsatzTV.Core.FFmpeg
return this; 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 complexFilter = new StringBuilder();
var videoLabel = $"{videoInput}:{videoStreamIndex}"; var 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); HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch bool isHardwareDecode = acceleration switch
{ {
HardwareAccelerationKind.Vaapi => _inputCodec != "mpeg4", HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4",
HardwareAccelerationKind.Nvenc => true, HardwareAccelerationKind.Nvenc => !isSong,
HardwareAccelerationKind.Qsv => true, HardwareAccelerationKind.Qsv => !isSong,
_ => false _ => false
}; };
@ -118,7 +153,7 @@ namespace ErsatzTV.Core.FFmpeg
var videoFilterQueue = new List<string>(); var videoFilterQueue = new List<string>();
string watermarkPreprocess = string.Empty; string watermarkPreprocess = string.Empty;
string watermarkOverlay = string.Empty; string watermarkOverlay = string.Empty;
if (_normalizeLoudness) if (_normalizeLoudness)
{ {
audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11"); audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11");
@ -133,9 +168,31 @@ namespace ErsatzTV.Core.FFmpeg
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode && bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome); (_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) if (_deinterlace)
@ -176,6 +233,7 @@ namespace ErsatzTV.Core.FFmpeg
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}", HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" => HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" =>
$"hwupload_cuda,scale_cuda={size.Width}:{size.Height}", $"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.Nvenc => $"scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}", HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear" _ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
@ -200,6 +258,8 @@ namespace ErsatzTV.Core.FFmpeg
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi", HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" => HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
"format=p010le,format=nv12", "format=p010le,format=nv12",
HardwareAccelerationKind.Qsv when isSong => "format=nv12,format=yuv420p",
_ when isSong => "format=yuv420p",
_ => "format=nv12" _ => "format=nv12"
}; };
videoFilterQueue.Add(format); videoFilterQueue.Add(format);
@ -210,6 +270,11 @@ namespace ErsatzTV.Core.FFmpeg
videoFilterQueue.Add("setsar=1"); videoFilterQueue.Add("setsar=1");
} }
if (isSong)
{
videoFilterQueue.Add("boxblur=75,fps=24");
}
foreach (ChannelWatermark watermark in _watermark) foreach (ChannelWatermark watermark in _watermark)
{ {
string enable = watermark.Mode == ChannelWatermarkMode.Intermittent string enable = watermark.Mode == ChannelWatermarkMode.Intermittent
@ -255,6 +320,11 @@ namespace ErsatzTV.Core.FFmpeg
} }
_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"));
foreach (string drawtext in _drawtext)
{
videoFilterQueue.Add(drawtext);
}
string outputPixelFormat = null; string outputPixelFormat = null;
@ -338,10 +408,21 @@ namespace ErsatzTV.Core.FFmpeg
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None) 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]"; videoLabel = "[v]";
complexFilter.Append(videoLabel); complexFilter.Append(videoLabel);
} }

33
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

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

79
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -48,7 +48,8 @@ namespace ErsatzTV.Core.FFmpeg
bool hlsRealtime, bool hlsRealtime,
FillerKind fillerKind, FillerKind fillerKind,
TimeSpan inPoint, TimeSpan inPoint,
TimeSpan outPoint) TimeSpan outPoint,
Option<string> drawtextFile)
{ {
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion); MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion); Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
@ -65,7 +66,7 @@ namespace ErsatzTV.Core.FFmpeg
outPoint); outPoint);
Option<WatermarkOptions> watermarkOptions = Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, audioVersion); await GetWatermarkOptions(channel, globalWatermark, videoVersion);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger) FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount) .WithThreads(playbackSettings.ThreadCount)
@ -86,6 +87,7 @@ namespace ErsatzTV.Core.FFmpeg
videoStream.Codec, videoStream.Codec,
videoStream.PixelFormat) videoStream.PixelFormat)
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution) .WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
.WithDrawtextFile(videoVersion, drawtextFile)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale) .WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None) .WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None)
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness); .WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
@ -222,17 +224,49 @@ namespace ErsatzTV.Core.FFmpeg
.Build(); .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) => private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) =>
displaySize.Width != target.Width || displaySize.Height != target.Height; displaySize.Width != target.Width || displaySize.Height != target.Height;
private async Task<WatermarkOptions> GetWatermarkOptions( private async Task<WatermarkOptions> GetWatermarkOptions(
Channel channel, Channel channel,
Option<ChannelWatermark> globalWatermark, 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 && if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo) channel.FFmpegProfile.NormalizeVideo)
{ {
if (videoVersion is CoverArtMediaVersion)
{
return new WatermarkOptions(
watermarkOverride,
videoVersion.MediaFiles.Head().Path,
0,
false);
}
// check for channel watermark // check for channel watermark
if (channel.Watermark != null) if (channel.Watermark != null)
{ {
@ -244,7 +278,7 @@ namespace ErsatzTV.Core.FFmpeg
ArtworkKind.Watermark, ArtworkKind.Watermark,
Option<int>.None); Option<int>.None);
return new WatermarkOptions( return new WatermarkOptions(
channel.Watermark, await watermarkOverride.IfNoneAsync(channel.Watermark),
customPath, customPath,
None, None,
await _imageCache.IsAnimated(customPath)); await _imageCache.IsAnimated(customPath));
@ -254,28 +288,12 @@ namespace ErsatzTV.Core.FFmpeg
.HeadOrNone() .HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None)); .Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions( return new WatermarkOptions(
channel.Watermark, await watermarkOverride.IfNoneAsync(channel.Watermark),
maybeChannelPath, maybeChannelPath,
None, None,
await maybeChannelPath.Match( await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p), p => _imageCache.IsAnimated(p),
() => Task.FromResult(false))); () => 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: default:
throw new NotSupportedException("Unsupported watermark image source"); throw new NotSupportedException("Unsupported watermark image source");
} }
@ -292,7 +310,7 @@ namespace ErsatzTV.Core.FFmpeg
ArtworkKind.Watermark, ArtworkKind.Watermark,
Option<int>.None); Option<int>.None);
return new WatermarkOptions( return new WatermarkOptions(
watermark, await watermarkOverride.IfNoneAsync(watermark),
customPath, customPath,
None, None,
await _imageCache.IsAnimated(customPath)); await _imageCache.IsAnimated(customPath));
@ -302,27 +320,12 @@ namespace ErsatzTV.Core.FFmpeg
.HeadOrNone() .HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None)); .Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions( return new WatermarkOptions(
watermark, await watermarkOverride.IfNoneAsync(watermark),
maybeChannelPath, maybeChannelPath,
None, None,
await maybeChannelPath.Match( await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p), p => _imageCache.IsAnimated(p),
() => Task.FromResult(false))); () => 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: default:
throw new NotSupportedException("Unsupported watermark image source"); throw new NotSupportedException("Unsupported watermark image source");
} }

19
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@ -47,6 +49,7 @@ namespace ErsatzTV.Core.Metadata
.ToList(); .ToList();
private readonly IImageCache _imageCache; private readonly IImageCache _imageCache;
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
@ -58,12 +61,14 @@ namespace ErsatzTV.Core.Metadata
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
IImageCache imageCache, IImageCache imageCache,
FFmpegProcessService ffmpegProcessService,
ILogger logger) ILogger logger)
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider; _localStatisticsProvider = localStatisticsProvider;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_imageCache = imageCache; _imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
_logger = logger; _logger = logger;
} }
@ -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); DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
@ -124,6 +129,18 @@ namespace ErsatzTV.Core.Metadata
try try
{ {
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile); _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 = Either<BaseError, string> maybeCacheName =
await _imageCache.CopyArtworkToCache(artworkFile, artworkKind); await _imageCache.CopyArtworkToCache(artworkFile, artworkKind);

6
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

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

7
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

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

3
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs

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

68
ErsatzTV.Core/Metadata/SongFolderScanner.cs

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@ -40,11 +41,13 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository, ISearchRepository searchRepository,
ISongRepository songRepository, ISongRepository songRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
FFmpegProcessService ffmpegProcessService,
ILogger<SongFolderScanner> logger) : base( ILogger<SongFolderScanner> logger) : base(
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
imageCache, imageCache,
ffmpegProcessService,
logger) logger)
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
@ -124,8 +127,8 @@ namespace ErsatzTV.Core.Metadata
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
.GetOrAdd(libraryPath, file) .GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath)) .BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath)); .BindT(video => UpdateMetadata(video, ffprobePath))
// .BindT(video => UpdateThumbnail(video, ffprobePath, ffmpegPath)); .BindT(video => UpdateThumbnail(video, ffprobePath, ffmpegPath));
await maybeSong.Match( await maybeSong.Match(
async result => async result =>
@ -201,27 +204,44 @@ namespace ErsatzTV.Core.Metadata
} }
} }
// private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail( private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail(
// MediaItemScanResult<Song> result, MediaItemScanResult<Song> result,
// string ffprobePath, string ffprobePath,
// string ffmpegPath) string ffmpegPath)
// { {
// try try
// { {
// Song song = result.Item; Song song = result.Item;
// await LocateThumbnail(song).IfSomeAsync( await LocateThumbnail(song).Match(
// async thumbnailFile => async thumbnailFile =>
// { {
// SongMetadata metadata = song.SongMetadata.Head(); SongMetadata metadata = song.SongMetadata.Head();
// await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail); await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, ffmpegPath);
// }); },
// () => Task.CompletedTask); // TODO: check for embedded artwork
// return result;
// } return result;
// catch (Exception ex) }
// { catch (Exception ex)
// return BaseError.New(ex.ToString()); {
// } 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;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@ -40,11 +41,13 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository, ISearchRepository searchRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IMediator mediator, IMediator mediator,
FFmpegProcessService ffmpegProcessService,
ILogger<TelevisionFolderScanner> logger) : base( ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
imageCache, imageCache,
ffmpegProcessService,
logger) logger)
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
@ -362,7 +365,7 @@ namespace ErsatzTV.Core.Metadata
async artworkFile => async artworkFile =>
{ {
ShowMetadata metadata = show.ShowMetadata.Head(); ShowMetadata metadata = show.ShowMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind); await RefreshArtwork(artworkFile, metadata, artworkKind, None);
}); });
return result; return result;
@ -381,7 +384,7 @@ namespace ErsatzTV.Core.Metadata
async posterFile => async posterFile =>
{ {
SeasonMetadata metadata = season.SeasonMetadata.Head(); SeasonMetadata metadata = season.SeasonMetadata.Head();
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster); await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None);
}); });
return season; return season;
@ -401,7 +404,7 @@ namespace ErsatzTV.Core.Metadata
{ {
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata) 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
WHERE A.ArtistMetadataId IS NULL AND A.EpisodeMetadataId IS NULL WHERE A.ArtistMetadataId IS NULL AND A.EpisodeMetadataId IS NULL
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId 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)") AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)")
.Map(result => result.ToList()); .Map(result => result.ToList());

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

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

1
ErsatzTV/ErsatzTV.csproj

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

1
ErsatzTV/Pages/WatermarkEditor.razor

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

4
ErsatzTV/Pages/Watermarks.razor

@ -38,10 +38,6 @@
{ {
<MudText>[channel logo]</MudText> <MudText>[channel logo]</MudText>
} }
else if (context.ImageSource == ChannelWatermarkImageSource.CoverArt)
{
<MudText>[cover art]</MudText>
}
</MudTd> </MudTd>
<MudTd DataLabel="Mode"> <MudTd DataLabel="Mode">
@context.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
await ExtractResource(assembly, "background.png", cancellationToken); await ExtractResource(assembly, "background.png", cancellationToken);
await ExtractResource(assembly, "ErsatzTV.png", cancellationToken); await ExtractResource(assembly, "ErsatzTV.png", cancellationToken);
await ExtractResource(assembly, "Roboto-Regular.ttf", cancellationToken); await ExtractResource(assembly, "Roboto-Regular.ttf", cancellationToken);
await ExtractResource(assembly, "OPTIKabel-Heavy.otf", cancellationToken);
} }
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;

Loading…
Cancel
Save