Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f230adc3cb | ||
|
|
56f94f489a | ||
|
|
475dc7660b | ||
|
|
db3dfbd446 | ||
|
|
b4c9cdbbfa | ||
|
|
7f84933c0b | ||
|
|
1e35e9a5b0 | ||
|
|
7edf6f5d13 | ||
|
|
919325033d | ||
|
|
2cb5252320 | ||
|
|
015232fad6 | ||
|
|
af51b790b6 | ||
|
|
9195ef7878 |
+22
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
+9
-126
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+3843
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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3843
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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user