Compare commits

...
Author SHA1 Message Date
Jason Dove f230adc3cb update changelog for release v0.3.1-alpha [no ci] 2021-11-30 10:51:00 -06:00
Jason DoveandGitHub 56f94f489a fix filler playout crash (#517) 2021-11-30 10:38:42 -06:00
Jason DoveandGitHub 475dc7660b fix artwork uploads (#516) 2021-11-29 14:34:18 -06:00
Jason DoveandGitHub db3dfbd446 disambiguate song search results (#515) 2021-11-27 21:37:55 -06:00
Jason DoveandGitHub b4c9cdbbfa use embedded song cover art (#514) 2021-11-27 21:08:18 -06:00
Jason DoveandGitHub 7f84933c0b index song genres (#513)
* add song genres to search index

* reset all song genre metadata

* update changelog and docs
2021-11-27 18:08:55 -06:00
Jason DoveandGitHub 1e35e9a5b0 use subtitles to display errors (#512)
* use subtitles to display errors

* fix margin calculation
2021-11-27 12:25:30 -06:00
Jason DoveandGitHub 7edf6f5d13 song cleanup (#511)
* refactor song background logic

* move song video generation

* move subtitle generation

* build ASS subtitles

* randomize song detail layout

* update changelog
2021-11-27 11:15:53 -06:00
Jason DoveandGitHub 919325033d use subtitles instead of drawtext for songs (#510) 2021-11-26 21:39:10 -06:00
Jason DoveandGitHub 2cb5252320 fix song banding (#509)
* increase spacing in song details; uniformly darken to eliminate banding

* this isn't needed anymore
2021-11-26 15:20:41 -06:00
Jason DoveandGitHub 015232fad6 song improvements (#508)
* fix song details margin and use dynamic font size

* sometimes use cover art color for song background
2021-11-26 13:23:28 -06:00
Jason DoveandGitHub af51b790b6 randomize cover art placement (#507) 2021-11-26 09:21:00 -06:00
Jason DoveandGitHub 9195ef7878 song fixes (#506)
* fix song page links

* show song artist in playout detail

* show more song details in channel guide
2021-11-26 08:49:04 -06:00
39 changed files with 8622 additions and 299 deletions
+22 -1
View File
@@ -5,6 +5,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.3.1-alpha] - 2021-11-30
### Fixed
- Fix song page links in UI
- Show song artist in playout detail
- Include song artist and cover art in channel guide (xmltv)
- Use subtitles to display errors, which fixes many edge cases of unescaped characters
- Properly split song genre tags
- Properly display all songs that have an identical album and title
- Fix channel logo and watermark uploads
- Fix regression introduced with `v0.2.4-alpha` that caused some filler edge cases to crash the playout builder
### Added
- Add song genres to search index
- Use embedded song cover art when sidecar cover art is unavailable
### Changed
- Randomly place song cover art on left or right side of screen
- Randomly use a solid color from the cover art instead of blurred cover art for song background
- Randomly select song detail layout (large title/small artist or small artist/title/album)
## [0.3.0-alpha] - 2021-11-25
### Fixed
- Properly fix database incompatibility introduced with `v0.2.4-alpha` and partially fixed with `v0.2.5-alpha`
@@ -817,7 +837,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.1-alpha...HEAD
[0.3.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...v0.3.1-alpha
[0.3.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.5-alpha...v0.3.0-alpha
[0.2.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.4-alpha...v0.2.5-alpha
[0.2.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.3-alpha...v0.2.4-alpha
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using System.IO;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
@@ -6,5 +7,5 @@ using MediatR;
namespace ErsatzTV.Application.Images.Commands
{
// ReSharper disable once SuggestBaseTypeForParameter
public record SaveArtworkToDisk(byte[] Buffer, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>;
public record SaveArtworkToDisk(Stream Stream, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>;
}
@@ -14,6 +14,6 @@ namespace ErsatzTV.Application.Images.Commands
public SaveArtworkToDiskHandler(IImageCache imageCache) => _imageCache = imageCache;
public Task<Either<BaseError, string>> Handle(SaveArtworkToDisk request, CancellationToken cancellationToken) =>
_imageCache.SaveArtworkToCache(request.Buffer, request.ArtworkKind);
_imageCache.SaveArtworkToCache(request.Stream, request.ArtworkKind);
}
}
+4 -1
View File
@@ -49,8 +49,11 @@ namespace ErsatzTV.Application.Playouts
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown video]");
case Song s:
string songArtist = s.SongMetadata.HeadOrNone()
.Map(sm => string.IsNullOrWhiteSpace(sm.Artist) ? string.Empty : $"{sm.Artist} - ")
.IfNone(string.Empty);
return s.SongMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")
.Map(t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? t : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown song]");
default:
@@ -1,20 +1,16 @@
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;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
@@ -41,8 +37,7 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo;
private readonly IImageCache _imageCache;
private readonly ITempFilePool _tempFilePool;
private readonly ISongVideoGenerator _songVideoGenerator;
public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
@@ -55,8 +50,7 @@ namespace ErsatzTV.Application.Streaming.Queries
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo,
IImageCache imageCache,
ITempFilePool tempFilePool)
ISongVideoGenerator songVideoGenerator)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
@@ -68,8 +62,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_runtimeInfo = runtimeInfo;
_imageCache = imageCache;
_tempFilePool = tempFilePool;
_songVideoGenerator = songVideoGenerator;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -142,121 +135,11 @@ namespace ErsatzTV.Application.Streaming.Queries
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
{
Option<string> drawtextFile = None;
videoVersion = new FallbackMediaVersion
{
Id = -1,
Chapters = new List<MediaChapter>(),
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string[] backgrounds =
{
"background_blank.png",
"background_e.png",
"background_t.png",
"background_v.png"
};
var random = new Random();
// use random ETV color by default
string artworkPath = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
backgrounds[random.Next() % backgrounds.Length]);
// use thumbnail (cover art) if present
foreach (SongMetadata metadata in song.SongMetadata)
{
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.DrawText);
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 }
}
};
}
}
videoPath = artworkPath;
videoVersion.MediaFiles = new List<MediaFile>
{
new() { Path = videoPath }
};
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
drawtextFile,
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
maybeGlobalWatermark,
videoVersion,
videoPath);
foreach (string si in maybeSongImage.RightToSeq())
{
videoPath = si;
videoVersion = new BackgroundImageMediaVersion
{
Chapters = new List<MediaChapter>(),
// song image has been pre-generated with correct size
Height = channel.FFmpegProfile.Resolution.Height,
Width = channel.FFmpegProfile.Resolution.Width,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 },
},
MediaFiles = new List<MediaFile>
{
new() { Path = si }
}
};
}
ffmpegPath);
}
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
@@ -310,7 +193,7 @@ namespace ErsatzTV.Application.Streaming.Queries
case UnableToLocatePlayoutItem:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = _ffmpegProcessService.ForError(
Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
@@ -329,7 +212,7 @@ namespace ErsatzTV.Application.Streaming.Queries
case PlayoutItemDoesNotExistOnDisk:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = _ffmpegProcessService.ForError(
Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
@@ -348,7 +231,7 @@ namespace ErsatzTV.Application.Streaming.Queries
default:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = _ffmpegProcessService.ForError(
Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
@@ -0,0 +1,90 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using FluentAssertions;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Scheduling
{
[TestFixture]
public class ShuffledMediaCollectionEnumeratorTests
{
private readonly List<GroupedMediaItem> _mediaItems = new()
{
new GroupedMediaItem(new MediaItem { Id = 1 }, new List<MediaItem>()),
new GroupedMediaItem(new MediaItem { Id = 2 }, new List<MediaItem>()),
new GroupedMediaItem(new MediaItem { Id = 3 }, new List<MediaItem>())
};
[Test]
public void Peek_Zero_Should_Match_Current()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state);
Option<MediaItem> peek = enumerator.Peek(0);
Option<MediaItem> current = enumerator.Current;
peek.IsSome.Should().BeTrue();
current.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(1);
current.ValueUnsafe().Id.Should().Be(1);
}
[Test]
public void Peek_One_Should_Match_Next()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state);
Option<MediaItem> peek = enumerator.Peek(1);
enumerator.MoveNext();
Option<MediaItem> next = enumerator.Current;
peek.IsSome.Should().BeTrue();
next.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(2);
next.ValueUnsafe().Id.Should().Be(2);
}
[Test]
public void Peek_Two_Should_Match_NextNext()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state);
Option<MediaItem> peek = enumerator.Peek(2);
enumerator.MoveNext();
enumerator.MoveNext();
Option<MediaItem> next = enumerator.Current;
peek.IsSome.Should().BeTrue();
next.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(3);
next.ValueUnsafe().Id.Should().Be(3);
}
[Test]
public void Peek_Three_Should_Match_NextNextNext()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state);
Option<MediaItem> peek = enumerator.Peek(3);
enumerator.MoveNext();
enumerator.MoveNext();
enumerator.MoveNext();
Option<MediaItem> next = enumerator.Current;
peek.IsSome.Should().BeTrue();
next.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(2);
next.ValueUnsafe().Id.Should().Be(2);
}
}
}
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
@@ -26,7 +25,9 @@ namespace ErsatzTV.Core.FFmpeg
private Option<int> _watermarkIndex;
private string _pixelFormat;
private string _videoEncoder;
private Option<string> _drawtext;
private Option<string> _subtitle;
private bool _boxBlur;
private Option<int> _randomColor;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@@ -94,34 +95,38 @@ namespace ErsatzTV.Core.FFmpeg
_watermarkIndex = watermarkIndex;
return this;
}
public FFmpegComplexFilterBuilder WithDrawtextFile(
MediaVersion videoVersion,
Option<string> drawtextFile)
public FFmpegComplexFilterBuilder WithBoxBlur(bool boxBlur)
{
foreach (string file in drawtextFile)
_boxBlur = boxBlur;
return this;
}
public FFmpegComplexFilterBuilder WithRandomColor(Option<int> randomColor)
{
_randomColor = randomColor;
return this;
}
public FFmpegComplexFilterBuilder WithSubtitleFile(Option<string> subtitleFile)
{
foreach (string file in subtitleFile)
{
string effectiveFile = file;
string fontsDir = FileSystemLayout.ResourcesCacheFolder;
if (videoVersion is FallbackMediaVersion or CoverArtMediaVersion)
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "OPTIKabel-Heavy.otf");
fontsDir = fontsDir
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
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";
effectiveFile = effectiveFile
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
}
_subtitle = $"subtitles={effectiveFile}:fontsdir={fontsDir}";
}
return this;
@@ -245,7 +250,7 @@ namespace ErsatzTV.Core.FFmpeg
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
};
if (!string.IsNullOrWhiteSpace(filter))
if (_randomColor.IsNone && !string.IsNullOrWhiteSpace(filter))
{
videoFilterQueue.Add(filter);
}
@@ -271,19 +276,20 @@ namespace ErsatzTV.Core.FFmpeg
videoFilterQueue.Add(format);
}
if (scaleOrPad)
if (scaleOrPad && _boxBlur == false && _randomColor.IsNone)
{
videoFilterQueue.Add("setsar=1");
}
if (videoOnly)
if (_boxBlur)
{
videoFilterQueue.Add("boxblur=40[b];[b]split[b1][b2];[b1]format=rgba,geq=r=0:g=0:b=0:a=120*(Y/H)[fg];[b2][fg]overlay=format=auto");
videoFilterQueue.Add("boxblur=40");
}
if (isSong)
foreach (int color in _randomColor)
{
videoFilterQueue.Add("fps=30");
videoFilterQueue.Add(
$"palettegen=max_colors=8,crop=1:1:{color}:0,scale={_resolution.Width}:{_resolution.Height},setsar=1");
}
foreach (ChannelWatermark watermark in _watermark)
@@ -332,9 +338,9 @@ 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)
foreach (string subtitle in _subtitle)
{
videoFilterQueue.Add(drawtext);
videoFilterQueue.Add(subtitle);
}
string outputPixelFormat = null;
+17 -45
View File
@@ -190,7 +190,14 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithInput(string input)
{
_arguments.Add("-i");
_arguments.Add($"{input}");
_arguments.Add(input);
return this;
}
public FFmpegProcessBuilder WithMap(string map)
{
_arguments.Add("-map");
_arguments.Add(map);
return this;
}
@@ -221,14 +228,9 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithDrawtextFile(
MediaVersion videoVersion,
Option<string> drawtextFile)
public FFmpegProcessBuilder WithSubtitleFile(Option<string> subtitleFile)
{
_complexFilterBuilder = _complexFilterBuilder.WithDrawtextFile(
videoVersion,
drawtextFile);
_complexFilterBuilder = _complexFilterBuilder.WithSubtitleFile(subtitleFile);
return this;
}
@@ -282,14 +284,18 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithSongInput(
string videoPath,
Option<string> codec,
Option<string> pixelFormat)
Option<string> pixelFormat,
bool boxBlur,
Option<int> randomColor)
{
_noAutoScale = true;
_outputFramerate = 30;
_complexFilterBuilder = _complexFilterBuilder
.WithInputCodec(codec)
.WithInputPixelFormat(pixelFormat);
.WithInputPixelFormat(pixelFormat)
.WithBoxBlur(boxBlur)
.WithRandomColor(randomColor);
_arguments.Add("-i");
_arguments.Add(videoPath);
@@ -297,24 +303,6 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithFiltergraph(string graph)
{
_arguments.Add("-vf");
_arguments.Add($"{graph}");
return this;
}
public FFmpegProcessBuilder WithFilterComplex(string filter, string finalVideo, string finalAudio)
{
_arguments.Add("-filter_complex");
_arguments.Add($"{filter}");
_arguments.Add("-map");
_arguments.Add(finalVideo);
_arguments.Add("-map");
_arguments.Add(finalAudio);
return this;
}
public FFmpegProcessBuilder WithConcat(string concatPlaylist)
{
_isConcat = true;
@@ -368,22 +356,6 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text)
{
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Roboto-Regular.ttf");
var fontFile = $"fontfile={fontPath}";
const string FONT_COLOR = "fontcolor=white";
const string X = "x=(w-text_w)/2";
const string Y = "y=(h-text_h)/3*2";
string fontSize = text.Length > 80 ? "fontsize=30" : text.Length > 60 ? "fontsize=40" : "fontsize=60";
return WithFilterComplex(
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={fontFile}:{fontSize}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
"[v]",
"1:a");
}
public FFmpegProcessBuilder WithDuration(TimeSpan duration)
{
_arguments.Add("-t");
+64 -24
View File
@@ -68,7 +68,7 @@ namespace ErsatzTV.Core.FFmpeg
outPoint);
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
@@ -167,7 +167,7 @@ namespace ErsatzTV.Core.FFmpeg
}
}
public Process ForError(
public async Task<Process> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
@@ -179,6 +179,22 @@ namespace ErsatzTV.Core.FFmpeg
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05);
string subtitleFile = await new SubtitleBuilder(_tempFilePool)
.WithResolution(desiredResolution)
.WithFontName("Roboto")
.WithFontSize(fontSize)
.WithAlignment(2)
.WithMarginV(margin)
.WithPrimaryColor("&HFFFFFF")
.WithFormattedContent(errorMessage.Replace(Environment.NewLine, "\\N"))
.BuildFile();
var videoStream = new MediaStream { Index = 0 };
var audioStream = new MediaStream { Index = 0 };
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
@@ -187,12 +203,18 @@ namespace ErsatzTV.Core.FFmpeg
.WithLoopedImage(Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"))
.WithLibavfilter()
.WithInput("anullsrc")
.WithErrorText(desiredResolution, errorMessage)
.WithSubtitleFile(subtitleFile)
.WithFilterComplex(
videoStream,
audioStream,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"),
"fake-audio-path",
playbackSettings.VideoCodec)
.WithPixfmt("yuv420p")
.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, None);
duration.IfSome(d => builder = builder.WithDuration(d));
await duration.IfSomeAsync(d => builder = builder.WithDuration(d));
switch (channel.StreamingMode)
{
@@ -235,13 +257,30 @@ namespace ErsatzTV.Core.FFmpeg
.Build();
}
public Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithInput(inputFile)
.WithMap($"0:{streamIndex}")
.WithOutputFormat("apng", outputFile)
.Build();
}
public async Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
Option<string> drawtextFile,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath)
string videoPath,
bool boxBlur,
Option<int> randomColor,
ChannelWatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent)
{
try
{
@@ -249,8 +288,22 @@ namespace ErsatzTV.Core.FFmpeg
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<ChannelWatermark> watermarkOverride =
videoVersion is FallbackMediaVersion or CoverArtMediaVersion
? new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
HorizontalMarginPercent = horizontalMarginPercent,
VerticalMarginPercent = verticalMarginPercent,
Location = watermarkLocation,
Size = ChannelWatermarkSize.Scaled,
WidthPercent = watermarkWidthPercent,
Opacity = 100
}
: None;
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
await GetWatermarkOptions(channel, globalWatermark, videoVersion, watermarkOverride);
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
@@ -270,10 +323,9 @@ namespace ErsatzTV.Core.FFmpeg
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur, randomColor)
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
.WithDrawtextFile(videoVersion, drawtextFile);
.WithSubtitleFile(subtitleFile);
foreach (IDisplaySize scaledSize in scalePlaybackSettings.ScaledSize)
{
@@ -317,26 +369,14 @@ namespace ErsatzTV.Core.FFmpeg
private async Task<WatermarkOptions> GetWatermarkOptions(
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion)
MediaVersion videoVersion,
Option<ChannelWatermark> watermarkOverride)
{
if (videoVersion is BackgroundImageMediaVersion)
{
return new WatermarkOptions(None, None, None, false);
}
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)
{
+238
View File
@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
public class SongVideoGenerator : ISongVideoGenerator
{
private static readonly Random Random = new();
private static readonly object RandomLock = new();
private readonly ITempFilePool _tempFilePool;
private readonly IImageCache _imageCache;
private readonly IFFmpegProcessService _ffmpegProcessService;
public SongVideoGenerator(
ITempFilePool tempFilePool,
IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService)
{
_tempFilePool = tempFilePool;
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
}
public async Task<Tuple<string, MediaVersion>> GenerateSongVideo(
Song song,
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath)
{
Option<string> subtitleFile = None;
MediaVersion videoVersion = new FallbackMediaVersion
{
Id = -1,
Chapters = new List<MediaChapter>(),
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string[] backgrounds =
{
"background_blank.png",
"background_e.png",
"background_t.png",
"background_v.png"
};
// use random ETV color by default
string artworkPath = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
backgrounds[NextRandom(backgrounds.Length)]);
var boxBlur = false;
Option<int> randomColor = None;
const int HORIZONTAL_MARGIN_PERCENT = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
const int WATERMARK_WIDTH_PERCENT = 25;
ChannelWatermarkLocation watermarkLocation = NextRandom(2) == 0
? ChannelWatermarkLocation.BottomLeft
: ChannelWatermarkLocation.BottomRight;
foreach (SongMetadata metadata in song.SongMetadata)
{
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
var largeFontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 10.0);
bool detailsStyle = NextRandom(2) == 0;
var sb = new StringBuilder();
if (detailsStyle)
{
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append($"{{\\fs{largeFontSize}}}{metadata.Title}");
}
if (!string.IsNullOrWhiteSpace(metadata.Artist))
{
sb.Append($"\\N{{\\fs{fontSize}}}{metadata.Artist}");
}
}
else
{
if (!string.IsNullOrWhiteSpace(metadata.Artist))
{
sb.Append(metadata.Artist);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append($"\\N\"{metadata.Title}\"");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.Append($"\\N{metadata.Album}");
}
}
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
switch (watermarkLocation)
{
case ChannelWatermarkLocation.BottomLeft:
leftMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
case ChannelWatermarkLocation.BottomRight:
leftMarginPercent = rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
rightMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
}
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Width);
var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Width);
var verticalMargin = (int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * channel.FFmpegProfile.Resolution.Height);
subtitleFile = await new SubtitleBuilder(_tempFilePool)
.WithResolution(channel.FFmpegProfile.Resolution)
.WithFontName("OPTIKabel-Heavy")
.WithFontSize(fontSize)
.WithPrimaryColor("&HFFFFFF")
.WithOutlineColor("&H555555")
.WithAlignment(0)
.WithMarginRight(rightMargin)
.WithMarginLeft(leftMargin)
.WithMarginV(verticalMargin)
.WithBorderStyle(1)
.WithShadow(3)
.WithFormattedContent(sb.ToString())
.BuildFile();
// use thumbnail (cover art) if present
foreach (Artwork artwork in Optional(
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
{
int backgroundRoll = NextRandom(16);
if (backgroundRoll < 8)
{
randomColor = backgroundRoll;
}
else
{
boxBlur = true;
}
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 }
}
};
}
}
string videoPath = artworkPath;
videoVersion.MediaFiles = new List<MediaFile>
{
new() { Path = videoPath }
};
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
subtitleFile,
channel,
maybeGlobalWatermark,
videoVersion,
videoPath,
boxBlur,
randomColor,
watermarkLocation,
HORIZONTAL_MARGIN_PERCENT,
VERTICAL_MARGIN_PERCENT,
WATERMARK_WIDTH_PERCENT);
foreach (string si in maybeSongImage.RightToSeq())
{
videoPath = si;
videoVersion = new BackgroundImageMediaVersion
{
Chapters = new List<MediaChapter>(),
// song image has been pre-generated with correct size
Height = channel.FFmpegProfile.Resolution.Height,
Width = channel.FFmpegProfile.Resolution.Width,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 },
},
MediaFiles = new List<MediaFile>
{
new() { Path = si }
}
};
}
return Tuple(videoPath, videoVersion);
}
private static int NextRandom(int max)
{
lock (RandomLock)
{
return Random.Next() % max;
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.IO;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
public class SubtitleBuilder
{
private readonly ITempFilePool _tempFilePool;
private string _content;
private Option<IDisplaySize> _resolution = None;
private Option<string> _fontName;
private Option<int> _fontSize;
private Option<string> _primaryColor;
private Option<string> _outlineColor;
private Option<int> _alignment;
private int _marginRight;
private int _marginLeft;
private int _marginV;
private Option<int> _borderStyle;
private Option<int> _shadow;
public SubtitleBuilder(ITempFilePool tempFilePool)
{
_tempFilePool = tempFilePool;
}
public SubtitleBuilder WithResolution(IDisplaySize resolution)
{
_resolution = Some(resolution);
return this;
}
public SubtitleBuilder WithFontName(string fontName)
{
_fontName = fontName;
return this;
}
public SubtitleBuilder WithFontSize(int fontSize)
{
_fontSize = fontSize;
return this;
}
public SubtitleBuilder WithPrimaryColor(string primaryColor)
{
_primaryColor = primaryColor;
return this;
}
public SubtitleBuilder WithOutlineColor(string outlineColor)
{
_outlineColor = outlineColor;
return this;
}
public SubtitleBuilder WithAlignment(int alignment)
{
_alignment = alignment;
return this;
}
public SubtitleBuilder WithMarginRight(int marginRight)
{
_marginRight = marginRight;
return this;
}
public SubtitleBuilder WithMarginLeft(int marginLeft)
{
_marginLeft = marginLeft;
return this;
}
public SubtitleBuilder WithMarginV(int marginV)
{
_marginV = marginV;
return this;
}
public SubtitleBuilder WithBorderStyle(int borderStyle)
{
_borderStyle = borderStyle;
return this;
}
public SubtitleBuilder WithShadow(int shadow)
{
_shadow = shadow;
return this;
}
public SubtitleBuilder WithFormattedContent(string content)
{
_content = content;
return this;
}
public async Task<string> BuildFile()
{
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
var sb = new StringBuilder();
sb.AppendLine("[Script Info]");
sb.AppendLine("ScriptType: v4.00+");
sb.AppendLine("WrapStyle: 0");
sb.AppendLine("ScaledBorderAndShadow: yes");
sb.AppendLine("YCbCr Matrix: None");
foreach (IDisplaySize resolution in _resolution)
{
sb.AppendLine($"PlayResX: {resolution.Width}");
sb.AppendLine($"PlayResY: {resolution.Height}");
}
sb.AppendLine("[V4+ Styles]");
sb.AppendLine("Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BorderStyle, Shadow, Alignment, Encoding");
sb.AppendLine($"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},{await _shadow.IfNoneAsync(0)}, {await _alignment.IfNoneAsync(0)},1");
sb.AppendLine("[Events]");
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
sb.AppendLine($"Dialogue: 0,0:00:00.00,99:99:99.99,Default,,{_marginLeft},{_marginRight},{_marginV},,{_content}");
if (!string.IsNullOrWhiteSpace(_content))
{
sb.AppendLine(_content);
}
await File.WriteAllTextAsync(fileName, sb.ToString());
return fileName;
}
}
}
+3 -2
View File
@@ -2,8 +2,9 @@
{
public enum TempFileCategory
{
DrawText = 0,
Subtitle = 0,
SongBackground = 1,
CoverArt = 2
CoverArt = 2,
CachedArtwork = 3
}
}
@@ -29,7 +29,7 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
TimeSpan inPoint,
TimeSpan outPoint);
Process ForError(
Task<Process> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
@@ -40,12 +40,20 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile);
Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
Option<string> drawtextFile,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath);
string videoPath,
bool boxBlur,
Option<int> randomColor,
ChannelWatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent);
}
}
@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface ISongVideoGenerator
{
Task<Tuple<string, MediaVersion>> GenerateSongVideo(
Song song,
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath);
}
}
@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
@@ -7,7 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Images
public interface IImageCache
{
Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height);
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind);
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
Task<bool> IsAnimated(string fileName);
+28 -2
View File
@@ -213,6 +213,29 @@ namespace ErsatzTV.Core.Iptv
}
}
}
if (!hasCustomTitle && displayItem.MediaItem is Song song)
{
xml.WriteStartElement("category");
xml.WriteAttributeString("lang", "en");
xml.WriteString("Music");
xml.WriteEndElement(); // category
foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone())
{
string thumbnail = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
.HeadOrNone()
.Match(a => GetArtworkUrl(a, ArtworkKind.Thumbnail), () => string.Empty);
if (!string.IsNullOrWhiteSpace(thumbnail))
{
xml.WriteStartElement("icon");
xml.WriteAttributeString("src", thumbnail);
xml.WriteEndElement(); // icon
}
}
}
if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
{
@@ -344,8 +367,8 @@ namespace ErsatzTV.Core.Iptv
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Title ?? string.Empty)
.IfNone("[unknown song]"),
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Artist ?? string.Empty)
.IfNone("[unknown artist]"),
_ => "[unknown]"
};
}
@@ -365,6 +388,9 @@ namespace ErsatzTV.Core.Iptv
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
+31 -7
View File
@@ -116,7 +116,12 @@ namespace ErsatzTV.Core.Metadata
}
}
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind, Option<string> ffmpegPath)
protected async Task<bool> RefreshArtwork(
string artworkFile,
Domain.Metadata metadata,
ArtworkKind artworkKind,
Option<string> ffmpegPath,
Option<int> attachedPicIndex)
{
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
@@ -134,15 +139,34 @@ namespace ErsatzTV.Core.Metadata
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
// if ffmpeg path is passed, we want to convert to png
// if ffmpeg path is passed, we need pre-processing
foreach (string path in ffmpegPath)
{
string tempName = _tempFilePool.GetNextTempFile(TempFileCategory.CoverArt);
using Process process = _ffmpegProcessService.ConvertToPng(path, artworkFile, tempName);
process.Start();
await process.WaitForExitAsync();
artworkFile = await attachedPicIndex.Match(
async picIndex =>
{
// extract attached pic (and convert to png)
string tempName = _tempFilePool.GetNextTempFile(TempFileCategory.CoverArt);
using Process process = _ffmpegProcessService.ExtractAttachedPicAsPng(
path,
artworkFile,
picIndex,
tempName);
process.Start();
await process.WaitForExitAsync();
artworkFile = tempName;
return tempName;
},
async () =>
{
// no attached pic index means convert to png
string tempName = _tempFilePool.GetNextTempFile(TempFileCategory.CoverArt);
using Process process = _ffmpegProcessService.ConvertToPng(path, artworkFile, tempName);
process.Start();
await process.WaitForExitAsync();
return tempName;
});
}
Either<BaseError, string> maybeCacheName =
@@ -228,9 +228,6 @@ namespace ErsatzTV.Core.Metadata
Tags = new List<Tag>()
};
// TODO: check for cover artwork, and use for watermark if not embedded
// maybe add album as entity rather than string?
if (tags.TryGetValue(MetadataFormatTag.Album, out string album))
{
result.Album = album;
@@ -248,14 +245,12 @@ namespace ErsatzTV.Core.Metadata
if (tags.TryGetValue(MetadataFormatTag.Genre, out string genre))
{
// TODO: split genres? or is this only ever one?
result.Genres.Add(new Genre { Name = genre });
result.Genres.AddRange(SplitGenres(genre).Map(n => new Genre { Name = n }));
}
if (tags.TryGetValue(MetadataFormatTag.Title, out string title))
{
result.Title = title;
result.OriginalTitle = title;
}
if (tags.TryGetValue(MetadataFormatTag.Track, out string track))
@@ -268,9 +263,10 @@ namespace ErsatzTV.Core.Metadata
if (string.IsNullOrWhiteSpace(result.Title))
{
result.Title = fallbackMetadata.Title;
result.OriginalTitle = fallbackMetadata.OriginalTitle;
}
result.OriginalTitle = fallbackMetadata.OriginalTitle;
// preserve folder tagging - maybe someone uses this
foreach (Tag tag in fallbackMetadata.Tags)
{
@@ -1140,5 +1136,17 @@ namespace ErsatzTV.Core.Metadata
return result;
}
private static IEnumerable<string> SplitGenres(string genre)
{
char[] delimiters = new[] { '/', '|', ';', '\\' }
.Filter(d => genre.IndexOf(d, StringComparison.OrdinalIgnoreCase) != -1)
.DefaultIfEmpty(',')
.ToArray();
return genre.Split(delimiters, StringSplitOptions.RemoveEmptyEntries)
.Where(i => !string.IsNullOrWhiteSpace(i))
.Select(i => i.Trim());
}
}
}
@@ -6,7 +6,7 @@ namespace ErsatzTV.Core.Metadata
{
public MediaItemScanResult(T item) => Item = item;
public T Item { get; }
public T Item { get; set; }
public bool IsAdded { get; set; }
public bool IsUpdated { get; set; }
+1 -1
View File
@@ -239,7 +239,7 @@ namespace ErsatzTV.Core.Metadata
async posterFile =>
{
MovieMetadata metadata = movie.MovieMetadata.Head();
await RefreshArtwork(posterFile, metadata, artworkKind, None);
await RefreshArtwork(posterFile, metadata, artworkKind, None, None);
});
return result;
@@ -222,7 +222,7 @@ namespace ErsatzTV.Core.Metadata
async artworkFile =>
{
ArtistMetadata metadata = artist.ArtistMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind, None);
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
});
return result;
@@ -385,7 +385,7 @@ namespace ErsatzTV.Core.Metadata
async thumbnailFile =>
{
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None);
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None);
});
return result;
+30 -4
View File
@@ -130,7 +130,7 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(video => UpdateThumbnail(video, ffprobePath, ffmpegPath));
.BindT(video => UpdateThumbnail(video, ffmpegPath));
await maybeSong.Match(
async result =>
@@ -208,19 +208,31 @@ namespace ErsatzTV.Core.Metadata
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail(
MediaItemScanResult<Song> result,
string ffprobePath,
string ffmpegPath)
{
try
{
// reload the song from the database at this point
if (result.IsAdded)
{
LibraryPath libraryPath = result.Item.LibraryPath;
string path = result.Item.GetHeadVersion().MediaFiles.Head().Path;
foreach (MediaItemScanResult<Song> s in (await _songRepository.GetOrAdd(libraryPath, path))
.RightToSeq())
{
result.Item = s.Item;
}
}
Song song = result.Item;
await LocateThumbnail(song).Match(
async thumbnailFile =>
{
SongMetadata metadata = song.SongMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, ffmpegPath);
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, ffmpegPath, None);
},
() => Task.CompletedTask); // TODO: check for embedded artwork
() => ExtractEmbeddedArtwork(song, ffmpegPath));
return result;
}
@@ -245,5 +257,19 @@ namespace ErsatzTV.Core.Metadata
.HeadOrNone();
}).Flatten();
}
private async Task ExtractEmbeddedArtwork(Song song, string ffmpegPath)
{
Option<MediaStream> maybeArtworkStream = Optional(song.GetHeadVersion().Streams.Find(ms => ms.AttachedPic));
foreach (MediaStream artworkStream in maybeArtworkStream)
{
await RefreshArtwork(
song.GetHeadVersion().MediaFiles.Head().Path,
song.SongMetadata.Head(),
ArtworkKind.Thumbnail,
ffmpegPath,
artworkStream.Index);
}
}
}
}
@@ -367,7 +367,7 @@ namespace ErsatzTV.Core.Metadata
async artworkFile =>
{
ShowMetadata metadata = show.ShowMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind, None);
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
});
return result;
@@ -386,7 +386,7 @@ namespace ErsatzTV.Core.Metadata
async posterFile =>
{
SeasonMetadata metadata = season.SeasonMetadata.Head();
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None);
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None, None);
});
return season;
@@ -406,7 +406,7 @@ namespace ErsatzTV.Core.Metadata
{
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail, None);
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail, None, None);
}
});
@@ -0,0 +1,41 @@
using System;
namespace ErsatzTV.Core.Scheduling
{
public class CloneableRandom
{
private readonly int _seed;
private readonly Random _random;
private int _count;
public CloneableRandom(int seed)
{
_seed = seed;
_random = new Random(_seed);
}
public CloneableRandom Clone()
{
var clone = new CloneableRandom(_seed);
for (var i = 0; i < _count; i++)
{
clone.Next();
}
return clone;
}
public int Next()
{
_count++;
return _random.Next();
}
public int Next(int maxValue)
{
_count++;
return _random.Next(maxValue);
}
}
}
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
@@ -13,7 +12,7 @@ namespace ErsatzTV.Core.Scheduling
{
private readonly int _mediaItemCount;
private readonly IList<GroupedMediaItem> _mediaItems;
private Random _random;
private CloneableRandom _random;
private IList<MediaItem> _shuffled;
public ShuffledMediaCollectionEnumerator(
@@ -29,7 +28,7 @@ namespace ErsatzTV.Core.Scheduling
state.Seed = new Random(state.Seed).Next();
}
_random = new Random(state.Seed);
_random = new CloneableRandom(state.Seed);
_shuffled = Shuffle(_mediaItems, _random);
State = new CollectionEnumeratorState { Seed = state.Seed };
@@ -45,7 +44,7 @@ namespace ErsatzTV.Core.Scheduling
public void MoveNext()
{
if ((State.Index + 1) % _shuffled.Count == 0)
if ((State.Index + 1) % _mediaItemCount == 0)
{
Option<MediaItem> tail = Current;
@@ -53,7 +52,7 @@ namespace ErsatzTV.Core.Scheduling
do
{
State.Seed = _random.Next();
_random = new Random(State.Seed);
_random = new CloneableRandom(State.Seed);
_shuffled = Shuffle(_mediaItems, _random);
} while (_mediaItems.Count > 1 && Current == tail);
}
@@ -62,29 +61,28 @@ namespace ErsatzTV.Core.Scheduling
State.Index++;
}
State.Index %= _shuffled.Count;
State.Index %= _mediaItemCount;
}
public Option<MediaItem> Peek(int offset)
{
if (offset == 0)
{
return Current;
}
if ((State.Index + offset) % _mediaItemCount == 0)
{
IList<MediaItem> shuffled;
Option<MediaItem> tail = Current;
// clone the random
var randomCopy = new Random();
FieldInfo seedArrayInfo = typeof(Random).GetField(
"_seedArray",
BindingFlags.NonPublic | BindingFlags.Instance);
var seedArray = seedArrayInfo.GetValue(_random) as int[];
int[] seedArrayCopy = seedArray.ToArray();
seedArrayInfo.SetValue(randomCopy, seedArrayCopy);
CloneableRandom randomCopy = _random.Clone();
do
{
int newSeed = randomCopy.Next();
randomCopy = new Random(newSeed);
randomCopy = new CloneableRandom(newSeed);
shuffled = Shuffle(_mediaItems, randomCopy);
} while (_mediaItems.Count > 1 && shuffled[0] == tail);
@@ -94,7 +92,7 @@ namespace ErsatzTV.Core.Scheduling
return _shuffled.Any() ? _shuffled[(State.Index + offset) % _mediaItemCount] : None;
}
private IList<MediaItem> Shuffle(IEnumerable<GroupedMediaItem> list, Random random)
private IList<MediaItem> Shuffle(IEnumerable<GroupedMediaItem> list, CloneableRandom random)
{
GroupedMediaItem[] copy = list.ToArray();
@@ -114,7 +114,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
MediaVersion incoming,
bool updateVersion = true)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<MediaVersion> maybeVersion = await dbContext.MediaVersions
.Include(v => v.Streams)
.Include(v => v.Chapters)
@@ -161,6 +161,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
existingStream.Title = incomingStream.Title;
existingStream.Default = incomingStream.Default;
existingStream.Forced = incomingStream.Forced;
existingStream.AttachedPic = incomingStream.AttachedPic;
existingStream.PixelFormat = incomingStream.PixelFormat;
existingStream.BitsPerRawSample = incomingStream.BitsPerRawSample;
}
var chaptersToAdd = incoming.Chapters
@@ -103,6 +103,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Song).SongMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Song).SongMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Song).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => mi.TraktListItems)
+24 -4
View File
@@ -5,6 +5,8 @@ using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
@@ -22,13 +24,19 @@ namespace ErsatzTV.Infrastructure.Images
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<ImageCache> _logger;
private readonly IMemoryCache _memoryCache;
private readonly ITempFilePool _tempFilePool;
static ImageCache() => Crypto = SHA1.Create();
public ImageCache(ILocalFileSystem localFileSystem, IMemoryCache memoryCache, ILogger<ImageCache> logger)
public ImageCache(
ILocalFileSystem localFileSystem,
IMemoryCache memoryCache,
ITempFilePool tempFilePool,
ILogger<ImageCache> logger)
{
_localFileSystem = localFileSystem;
_memoryCache = memoryCache;
_tempFilePool = tempFilePool;
_logger = logger;
}
@@ -53,11 +61,14 @@ namespace ErsatzTV.Infrastructure.Images
return outStream.ToArray();
}
public async Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind)
public async Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind)
{
try
{
byte[] hash = Crypto.ComputeHash(imageBuffer);
string tempFileName = _tempFilePool.GetNextTempFile(TempFileCategory.CachedArtwork);
await using var fs = new FileStream(tempFileName, FileMode.OpenOrCreate);
await stream.CopyToAsync(fs);
byte[] hash = await ComputeFileHash(tempFileName);
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string subfolder = hex[..2];
string baseFolder = artworkKind switch
@@ -76,7 +87,8 @@ namespace ErsatzTV.Infrastructure.Images
Directory.CreateDirectory(baseFolder);
}
await File.WriteAllBytesAsync(target, imageBuffer);
await _localFileSystem.CopyFile(tempFileName, target);
return hex;
}
catch (Exception ex)
@@ -85,6 +97,14 @@ namespace ErsatzTV.Infrastructure.Images
}
}
private static async Task<byte[]> ComputeFileHash(string fileName)
{
using var md5 = MD5.Create();
await using var fs = new FileStream(fileName, FileMode.Open);
fs.Position = 0;
return await md5.ComputeHashAsync(fs);
}
public async Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind)
{
try
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_SongMetadataGenres : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"DELETE FROM Genre WHERE SongMetadataId IS NOT NULL");
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 5)");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 5");
migrationBuilder.Sql(
@"UPDATE SongMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
(SELECT LF.Id FROM LibraryFolder LF INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id INNER JOIN Library L on LP.LibraryId = L.Id WHERE MediaKind = 5)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_SongMetadataOriginalTitle : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 5)");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 5");
migrationBuilder.Sql(
@"UPDATE SongMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
(SELECT LF.Id FROM LibraryFolder LF INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id INNER JOIN Library L on LP.LibraryId = L.Id WHERE MediaKind = 5)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -867,6 +867,11 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, song.Id.ToString()), doc);
}
catch (Exception ex)
@@ -903,6 +908,7 @@ namespace ErsatzTV.Infrastructure.Search
$"{em.Title}_{em.Year}_{em.Episode.Season.SeasonNumber}_{em.EpisodeNumber}"
.ToLowerInvariant(),
OtherVideoMetadata ovm => $"{ovm.OriginalTitle}".ToLowerInvariant(),
SongMetadata sm => $"{sm.OriginalTitle}".ToLowerInvariant(),
_ => $"{metadata.Title}_{metadata.Year}".ToLowerInvariant()
};
+5 -4
View File
@@ -199,9 +199,8 @@
{
try
{
var buffer = new byte[e.File.Size];
await e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024).ReadAsync(buffer);
Either<BaseError, string> maybeCacheFileName = await _mediator.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Logo));
Either<BaseError, string> maybeCacheFileName =
await _mediator.Send(new SaveArtworkToDisk(e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024), ArtworkKind.Logo));
maybeCacheFileName.Match(
relativeFileName =>
{
@@ -210,12 +209,14 @@
},
error =>
{
Console.WriteLine($"error saving {error}");
_snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
_logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
});
}
catch (IOException)
catch (IOException ex)
{
Console.WriteLine(ex);
_snackbar.Add("Channel logo exceeds maximum allowed file size of 10 MB", Severity.Error);
_logger.LogError("Channel logo exceeds maximum allowed file size of 10 MB");
}
+5 -5
View File
@@ -1,5 +1,5 @@
@page "/music/songs"
@page "/music/songs/page/{PageNumber:int}"
@page "/media/music/songs"
@page "/media/music/songs/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@@ -69,7 +69,7 @@
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/music/songs"
BaseUri="/media/music/songs"
Query="@_query"/>
}
@@ -101,7 +101,7 @@
private void PrevPage()
{
var uri = $"/music/songs/page/{PageNumber - 1}";
var uri = $"/media/music/songs/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
@@ -112,7 +112,7 @@
private void NextPage()
{
var uri = $"/music/songs/page/{PageNumber + 1}";
var uri = $"/media/music/songs/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
+1 -3
View File
@@ -209,10 +209,8 @@
{
try
{
var buffer = new byte[e.File.Size];
await e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024).ReadAsync(buffer);
Either<BaseError, string> maybeCacheFileName = await _mediator
.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Watermark));
.Send(new SaveArtworkToDisk(e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024), ArtworkKind.Watermark));
maybeCacheFileName.Match(
relativeFileName =>
{
+1 -1
View File
@@ -54,7 +54,7 @@
<MudNavLink Href="/media/movies">Movies</MudNavLink>
<MudNavLink Href="/media/music/artists">Music</MudNavLink>
<MudNavLink Href="/media/other/videos">Other Videos</MudNavLink>
<MudNavLink Href="/music/songs">Songs</MudNavLink>
<MudNavLink Href="/media/music/songs">Songs</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Lists" Expanded="true">
<MudNavLink Href="/media/collections">Collections</MudNavLink>
+1
View File
@@ -311,6 +311,7 @@ namespace ErsatzTV
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<IFFmpegProcessService, FFmpegProcessService>();
services.AddScoped<ISongVideoGenerator, SongVideoGenerator>();
services.AddScoped<HlsSessionWorker>();
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
+1
View File
@@ -97,6 +97,7 @@ The following fields are available for searching songs:
- `title`: The song title, or the filename of the song (without extension)
- `album`: The song album
- `artist`: The song artist
- `genre`: The song genre
- `tag`: All of the song's parent folders
- `minutes`: the rounded-up whole number duration of the song in minutes
- `type`: Always `song`