Compare commits

..
Author SHA1 Message Date
Jason Dove 73887706ed update changelog for release v0.3.2-alpha [no ci] 2021-12-03 14:57:19 -06:00
Jason DoveandGitHub abc103308b optimize song artwork scanning (#527) 2021-12-03 13:40:55 -06:00
Jason DoveandGitHub 3773bbec19 use blurhash for song backgrounds (#526)
* generate blurhash for all local artwork

* use blurhash song background if available

* only write blur hash to disk once

* use multiple blur hashes

* update changelog

* fix song detail outline

* reset song metadata (artwork)
2021-12-03 12:30:47 -06:00
Jason DoveandGitHub e223d6a43f remove unused cli project (#525) [no ci] 2021-12-02 09:01:47 -06:00
Jason DoveandGitHub 8369111e31 update dependencies (#524) 2021-12-02 08:45:43 -06:00
Jason DoveandGitHub 35ba2bab2c fix unicode song metadata on windows (#523)
* test setting utf8 encoding with ffprobe

* use utf8 encoding for console (logging) output

* use proper sink package

* reset song metadata on windows

* fix nfo processing with missing year

* update changelog
2021-12-01 13:43:09 -06:00
Jason DoveandGitHub 094ed71ad0 fix docker builds with custom (local) nuget package (#520) 2021-11-30 20:49:02 -06:00
Jason DoveandGitHub 89e24b2b78 use custom log database backend that is more portable (#519) 2021-11-30 20:34:14 -06:00
Jason DoveandGitHub 848795af32 fix artwork upload on windows (#518)
* update changelog for release v0.3.1-alpha [no ci]

* fix artwork upload on windows
2021-11-30 12:23:24 -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
Jason Dove dfc4c7a284 update changelog for release v0.3.0-alpha [no ci] 2021-11-25 20:49:23 -06:00
Jason DoveandGitHub a6b15f68c9 randomize default backgrounds (#504)
* randomize default song backgrounds

* update docs
2021-11-25 20:19:26 -06:00
Jason DoveandGitHub 0edfb71f8d limit disk use and keep cover art aspect ratio (#502)
* use temp file pool to limit disk use

* keep aspect ratio and crop when scaling cover art for blurred background

* fix typo
2021-11-25 18:47:22 -06:00
Jason DoveandGitHub 21b90a1b6c fix songs with white backgrounds (#501) 2021-11-25 15:36:40 -06:00
Jason Dove 1582f5dd15 update changelog [no ci] 2021-11-25 13:37:33 -06:00
Jason DoveandGitHub fd3b72525d fix vaapi songs (#500) 2021-11-25 13:35:57 -06:00
Jason DoveandGitHub 55d1871d94 re-enable hardware acceleration for songs (#499) 2021-11-25 13:04:13 -06:00
Jason DoveandGitHub a90eb2d4de optimize generated video (#498)
* use different framerate flags

* pre-generate song image and always use software encoders

* fix tests
2021-11-25 12:31:57 -06:00
Jason DoveandGitHub ed3f1b1dad generate song video (#497)
* use blurred cover art as song background

* use channel watermark when cover art is unavailable

* add drawtext to song filter

* cleanup

* force song cover art as png

* fix songs on windows and qsv
2021-11-25 06:22:38 -06:00
Jason DoveandGitHub 8e08ff059f load embedded song metadata (#495)
* load embedded song metadata

* index song artist and song album

* reset all song metadata
2021-11-24 07:31:34 -06:00
Jason DoveandGitHub fb8c3a0453 disable autoscale when looping with vaapi or qsv (#494) 2021-11-23 13:25:23 -06:00
Jason DoveandGitHub e45fb67769 bug fixes (#493)
* don't align audio when playing songs

* fix grouping duration items in epg
2021-11-23 11:44:39 -06:00
Jason DoveandGitHub 3a40d6ce77 fix local library locking when adding paths (#492) 2021-11-23 10:54:34 -06:00
Jason DoveandGitHub ac048b72ae add cover art watermark source (#491)
* add cover art watermark source

* update changelog
2021-11-23 10:02:36 -06:00
Jason DoveandGitHub 852728c816 add songs libraries (#490)
* first pass at adding song libraries

* start handling optional video

* fix song playback

* fix song transitions

* add songs page to UI
2021-11-22 22:26:06 -06:00
Jason DoveandGitHub 096f2d42e8 properly fix database upgrade (#489) 2021-11-22 17:56:29 -06:00
Jason Dove 1b29e252ff update changelog for release v0.2.5-alpha [no ci] 2021-11-21 07:24:20 -06:00
Jason DoveandGitHub a4dc9bfb31 Ignore local plex guids (#488)
* ignore local plex guids

* update dependencies
2021-11-21 06:25:56 -06:00
Jason DoveandGitHub 184c21a91b optimize trakt matching (#487) 2021-11-21 06:13:28 -06:00
Jason DoveandGitHub 6ea3191cf8 fix playout building (#486) 2021-11-20 22:36:15 -06:00
Jason DoveandGitHub d487bbca08 include other video title in channel guide (#483) 2021-11-16 08:46:07 -06:00
Jason Dove 05034b47e2 update changelog for release v0.2.4-alpha [no ci] 2021-11-13 12:54:41 -06:00
Jason DoveandGitHub b0c85b6478 use scale_cuda instead of scale_npp (#481) 2021-11-13 09:06:02 -06:00
Jason DoveandGitHub f1356563da fix ef shared table warnings (#480) 2021-11-10 18:03:28 -06:00
Jason DoveandGitHub c0aad028a8 more dotnet 6 updates (#479) 2021-11-09 13:09:57 -06:00
Jason DoveandGitHub dae06ec0ef upgrade to dotnet 6 (#475) 2021-11-09 07:44:34 -06:00
Jason DoveandGitHub 72f452fd36 update dependencies (#474) 2021-11-09 06:16:26 -06:00
Jason Dove aaf832c0b6 update changelog for release v0.2.3-alpha [no ci] 2021-11-03 13:53:06 -05:00
Jason DoveandGitHub 08a18daf23 movie scanner should respect .etvignore files (#468) 2021-11-03 05:47:29 -05:00
Jason DoveandGitHub 90c1c61a09 fix bug with flood playout mode (#467) 2021-11-02 21:47:46 -05:00
Jason DoveandGitHub 053db71d44 fix decimal separator in ffmpeg apad filter syntax (#464) 2021-11-01 22:18:06 -05:00
174 changed files with 54663 additions and 1734 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"jetbrains.resharper.globaltools": { "jetbrains.resharper.globaltools": {
"version": "2021.1.3", "version": "2021.2.2",
"commands": [ "commands": [
"jb" "jb"
] ]
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: Setup .NET Core - name: Setup .NET Core
uses: actions/setup-dotnet@v1 uses: actions/setup-dotnet@v1
with: with:
dotnet-version: 5.0.x dotnet-version: 6.0.x
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear
+6 -6
View File
@@ -18,8 +18,11 @@ jobs:
kind: windows kind: windows
target: win-x64 target: win-x64
- os: macos-latest - os: macos-latest
kind: maxOS kind: macOS
target: osx-x64 target: osx-x64
- os: macos-latest
kind: macOS
target: osx-arm64
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Get the sources - name: Get the sources
@@ -28,7 +31,7 @@ jobs:
- name: Setup .NET Core - name: Setup .NET Core
uses: actions/setup-dotnet@v1 uses: actions/setup-dotnet@v1
with: with:
dotnet-version: 5.0.x dotnet-version: 6.0.x
- name: Clean - name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -44,14 +47,11 @@ jobs:
release_name="ErsatzTV-$tag-${{ matrix.target }}" release_name="ErsatzTV-$tag-${{ matrix.target }}"
# Build everything # Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:PublishSingleFile=true --self-contained true dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:EnableCompressionInSingleFile=true /property:PublishSingleFile=true --self-contained true
# Pack files # Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then if [ "${{ matrix.target }}" == "win-x64" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*" 7z a -tzip "${release_name}.zip" "./${release_name}/*"
elif [ "${{ matrix.target }}" == "linux-arm" ]; then
cp lib/linux-arm/* "$release_name/"
tar czvf "${release_name}.tar.gz" "$release_name"
else else
tar czvf "${release_name}.tar.gz" "$release_name" tar czvf "${release_name}.tar.gz" "$release_name"
fi fi
+82 -1
View File
@@ -5,6 +5,80 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
## [0.3.2-alpha] - 2021-12-03
### Fixed
- Fix artwork upload on Windows
- Fix unicode song metadata on Windows
- Fix unicode console output on Windows
- Fix TV Show NFO metadata processing when `year` is missing
- Fix song detail outline to help legibility on white backgrounds
- Optimize song artwork scanning to prevent re-processing album artwork for each song
### Changed
- Use custom log database backend which should be more portable (i.e. work in osx-arm64)
- Use cover art blurhashes for song backgrounds instead of solid colors or box blur
## [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`
- The proper fix requires rebuilding all playouts, which will happen on startup after upgrading
- Fix local library locking/progress display when adding paths
- Fix grouping duration items in EPG when custom title is configured
### Added
- Add *experimental* `Songs` local libraries
- Like `Other Videos`, `Songs` require no metadata or particular folder layout, and will have tags added for each containing folder
- For Example, a song at `rock/band/1990 - Album/01 whatever.flac` will have the tags `rock`, `band` and `1990 - Album`, and the title `01 whatever`
- Songs will also have basic metadata read from embedded tags (album, artist, title)
- Video will be automatically generated for songs using metadata and cover art or watermarks if available
- Add support for `.webm` video files
## [0.2.5-alpha] - 2021-11-21
### Fixed
- Include other video title in channel guide (xmltv)
- Fix bug introduced with 0.2.4-alpha that caused some playouts to build from year 0
- Use less memory matching Trakt list items
### Added
- Build osx-arm64 packages on release
### Changed
- No longer warn about local Plex guids; they aren't used for Trakt matching and can be ignored
## [0.2.4-alpha] - 2021-11-13
### Changed
- Upgrade to dotnet 6
- Use `scale_cuda` instead of `scale_npp` for NVIDIA scaling in all cases
## [0.2.3-alpha] - 2021-11-03
### Fixed
- Fix bug with audio filter in cultures where `.` is a group/thousands separator
- Fix bug where flood playout mode would only schedule one item
- This would happen if the flood was followed by another flood with a fixed start time
### Added
- Support empty `.etvignore` file to instruct local movie scanner to ignore the containing folder
## [0.2.2-alpha] - 2021-10-30 ## [0.2.2-alpha] - 2021-10-30
### Fixed ### Fixed
- Fix EPG entries for Duration schedule items that play multiple items - Fix EPG entries for Duration schedule items that play multiple items
@@ -776,7 +850,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker. - Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.1-alpha...HEAD [Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.2-alpha...HEAD
[0.3.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.1-alpha...v0.3.2-alpha
[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
[0.2.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.2-alpha...v0.2.3-alpha
[0.2.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.1-alpha...v0.2.2-alpha
[0.2.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.0-alpha...v0.2.1-alpha [0.2.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.0-alpha...v0.2.1-alpha
[0.2.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.5-alpha...v0.2.0-alpha [0.2.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.5-alpha...v0.2.0-alpha
[0.1.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.4-alpha...v0.1.5-alpha [0.1.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.4-alpha...v0.1.5-alpha
@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType> <DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" Version="9.0.0" /> <PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -1,4 +1,5 @@
using ErsatzTV.Core; using System.IO;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
@@ -6,5 +7,5 @@ using MediatR;
namespace ErsatzTV.Application.Images.Commands namespace ErsatzTV.Application.Images.Commands
{ {
// ReSharper disable once SuggestBaseTypeForParameter // 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 SaveArtworkToDiskHandler(IImageCache imageCache) => _imageCache = imageCache;
public Task<Either<BaseError, string>> Handle(SaveArtworkToDisk request, CancellationToken cancellationToken) => public Task<Either<BaseError, string>> Handle(SaveArtworkToDisk request, CancellationToken cancellationToken) =>
_imageCache.SaveArtworkToCache(request.Buffer, request.ArtworkKind); _imageCache.SaveArtworkToCache(request.Stream, request.ArtworkKind);
} }
} }
@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Linq;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Channels; using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -37,7 +36,7 @@ namespace ErsatzTV.Application.Libraries.Commands
CreateLocalLibrary request, CreateLocalLibrary request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request); Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary)); return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
} }
@@ -43,7 +43,7 @@ namespace ErsatzTV.Application.Libraries.Commands
UpdateLocalLibrary request, UpdateLocalLibrary request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters)); return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
} }
@@ -53,7 +53,6 @@ namespace ErsatzTV.Application.Libraries.Commands
(LocalLibrary existing, LocalLibrary incoming) = parameters; (LocalLibrary existing, LocalLibrary incoming) = parameters;
existing.Name = incoming.Name; existing.Name = incoming.Name;
// toAdd
var toAdd = incoming.Paths var toAdd = incoming.Paths
.Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path))) .Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path)))
.ToList(); .ToList();
@@ -77,7 +76,7 @@ namespace ErsatzTV.Application.Libraries.Commands
_searchIndex.Commit(); _searchIndex.Commit();
} }
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id)) if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id))
{ {
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id)); await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
} }
@@ -10,7 +10,8 @@ namespace ErsatzTV.Application.MediaCards
List<TelevisionEpisodeCardViewModel> EpisodeCards, List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards, List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards, List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards) List<OtherVideoCardViewModel> OtherVideoCards,
List<SongCardViewModel> SongCards)
{ {
public bool UseCustomPlaybackOrder { get; set; } public bool UseCustomPlaybackOrder { get; set; }
} }
+13 -1
View File
@@ -110,6 +110,16 @@ namespace ErsatzTV.Application.MediaCards
otherVideoMetadata.OriginalTitle, otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle); otherVideoMetadata.SortTitle);
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
{
string album = string.IsNullOrWhiteSpace(songMetadata.Album) ? "" : $" - {songMetadata.Album}";
return new SongCardViewModel(
songMetadata.SongId,
songMetadata.Title,
songMetadata.Artist + album,
songMetadata.SortTitle);
}
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) => internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new( new(
artistMetadata.ArtistId, artistMetadata.ArtistId,
@@ -141,7 +151,9 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(), collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head())) collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList(), .ToList(),
collection.MediaItems.OfType<OtherVideo>().Map(mv => ProjectToViewModel(mv.OtherVideoMetadata.Head())) collection.MediaItems.OfType<OtherVideo>().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder }; .ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel( internal static ActorCardViewModel ProjectToViewModel(
@@ -30,7 +30,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
GetCollectionCards request, GetCollectionCards request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone()); .Map(list => list.HeadOrNone());
@@ -83,6 +83,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Artwork) .ThenInclude(ovm => ovm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(ovm => ovm.Artwork)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id) .SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection"))) .Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby)); .MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record SongCardResultsViewModel(
int Count,
List<SongCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,17 @@
namespace ErsatzTV.Application.MediaCards
{
public record SongCardViewModel
(
int SongId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
SongId,
Title,
Subtitle,
SortTitle,
null)
{
public int CustomIndex { get; set; }
}
}
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands
List<int> EpisodeIds, List<int> EpisodeIds,
List<int> ArtistIds, List<int> ArtistIds,
List<int> MusicVideoIds, List<int> MusicVideoIds,
List<int> OtherVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>; List<int> OtherVideoIds,
List<int> SongIds) : MediatR.IRequest<Either<BaseError, Unit>>;
} }
@@ -57,6 +57,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Append(request.ArtistIds) .Append(request.ArtistIds)
.Append(request.MusicVideoIds) .Append(request.MusicVideoIds)
.Append(request.OtherVideoIds) .Append(request.OtherVideoIds)
.Append(request.SongIds)
.ToList(); .ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList(); var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddSongToCollection
(int CollectionId, int SongId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,80 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddSongToCollectionHandler :
MediatR.IRequestHandler<AddSongToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public AddSongToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
AddSongToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddSongRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddSongRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Song);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
AddSongToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateSong(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext,
AddSongToCollection request) =>
dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private static Task<Validation<BaseError, Song>> ValidateSong(
TvContext dbContext,
AddSongToCollection request) =>
dbContext.Songs
.SelectOneAsync(m => m.Id, e => e.Id == request.SongId)
.Map(o => o.ToValidation<BaseError>("Song does not exist"));
private record Parameters(Collection Collection, Song Song);
}
}
@@ -39,7 +39,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{ {
try try
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId); Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
return await validation.Match( return await validation.Match(
@@ -208,6 +208,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
var guids = item.Guids.Map(g => g.Guid).ToList(); var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
.AsNoTracking()
.Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid))) .Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -220,6 +221,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
} }
Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata
.AsNoTracking()
.Filter(mm => mm.Title == item.Title && mm.Year == item.Year) .Filter(mm => mm.Title == item.Title && mm.Year == item.Year)
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -241,6 +243,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
var guids = item.Guids.Map(g => g.Guid).ToList(); var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeShowByGuid = await dbContext.ShowMetadata Option<int> maybeShowByGuid = await dbContext.ShowMetadata
.AsNoTracking()
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid))) .Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -253,6 +256,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
} }
Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata
.AsNoTracking()
.Filter(sm => sm.Title == item.Title && sm.Year == item.Year) .Filter(sm => sm.Title == item.Title && sm.Year == item.Year)
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -274,6 +278,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
var guids = item.Guids.Map(g => g.Guid).ToList(); var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
.AsNoTracking()
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid))) .Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -286,6 +291,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
} }
Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata
.AsNoTracking()
.Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year)) .Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
.Filter(sm => sm.Season.SeasonNumber == item.Season) .Filter(sm => sm.Season.SeasonNumber == item.Season)
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
@@ -308,6 +314,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
var guids = item.Guids.Map(g => g.Guid).ToList(); var guids = item.Guids.Map(g => g.Guid).ToList();
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => em.Guids.Any(g => guids.Contains(g.Guid))) .Filter(em => em.Guids.Any(g => guids.Contains(g.Guid)))
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(Optional) .Map(Optional)
@@ -320,6 +327,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
} }
Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year)) .Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
.Filter(em => em.Episode.Season.SeasonNumber == item.Season) .Filter(em => em.Episode.Season.SeasonNumber == item.Season)
.Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode)) .Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode))
@@ -27,6 +27,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private readonly IMovieFolderScanner _movieFolderScanner; private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner; private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner; private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
private readonly ISongFolderScanner _songFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner; private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler( public ScanLocalLibraryHandler(
@@ -36,6 +37,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
ITelevisionFolderScanner televisionFolderScanner, ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner, IMusicVideoFolderScanner musicVideoFolderScanner,
IOtherVideoFolderScanner otherVideoFolderScanner, IOtherVideoFolderScanner otherVideoFolderScanner,
ISongFolderScanner songFolderScanner,
IEntityLocker entityLocker, IEntityLocker entityLocker,
IMediator mediator, IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger) ILogger<ScanLocalLibraryHandler> logger)
@@ -46,6 +48,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
_televisionFolderScanner = televisionFolderScanner; _televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner; _musicVideoFolderScanner = musicVideoFolderScanner;
_otherVideoFolderScanner = otherVideoFolderScanner; _otherVideoFolderScanner = otherVideoFolderScanner;
_songFolderScanner = songFolderScanner;
_entityLocker = entityLocker; _entityLocker = entityLocker;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
@@ -67,7 +70,8 @@ namespace ErsatzTV.Application.MediaSources.Commands
private async Task<Unit> PerformScan(RequestParameters parameters) private async Task<Unit> PerformScan(RequestParameters parameters)
{ {
(LocalLibrary localLibrary, string ffprobePath, bool forceScan, int libraryRefreshInterval) = parameters; (LocalLibrary localLibrary, string ffprobePath, string ffmpegPath, bool forceScan,
int libraryRefreshInterval) = parameters;
var sw = new Stopwatch(); var sw = new Stopwatch();
sw.Start(); sw.Start();
@@ -117,6 +121,14 @@ namespace ErsatzTV.Application.MediaSources.Commands
progressMin, progressMin,
progressMax); progressMax);
break; break;
case LibraryMediaKind.Songs:
await _songFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffmpegPath,
progressMin,
progressMax);
break;
} }
libraryPath.LastScan = DateTime.UtcNow; libraryPath.LastScan = DateTime.UtcNow;
@@ -149,11 +161,12 @@ namespace ErsatzTV.Application.MediaSources.Commands
} }
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) => private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) =>
(await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateLibraryRefreshInterval()) (await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateFFmpegPath(), await ValidateLibraryRefreshInterval())
.Apply( .Apply(
(library, ffprobePath, libraryRefreshInterval) => new RequestParameters( (library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
library, library,
ffprobePath, ffprobePath,
ffmpegPath,
request.ForceScan, request.ForceScan,
libraryRefreshInterval)); libraryRefreshInterval));
@@ -170,6 +183,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
ffprobePath => ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system")); ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(
ffmpegPath =>
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() => private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval) _configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0) .FilterT(lri => lri > 0)
@@ -178,6 +198,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private record RequestParameters( private record RequestParameters(
LocalLibrary LocalLibrary, LocalLibrary LocalLibrary,
string FFprobePath, string FFprobePath,
string FFmpegPath,
bool ForceScan, bool ForceScan,
int LibraryRefreshInterval); int LibraryRefreshInterval);
} }
+8
View File
@@ -48,6 +48,14 @@ namespace ErsatzTV.Application.Playouts
.Map(ovm => ovm.Title ?? string.Empty) .Map(ovm => ovm.Title ?? string.Empty)
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})") .Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown video]"); .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 => $"{songArtist}{sm.Title ?? string.Empty}")
.Map(t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? t : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown song]");
default: default:
return string.Empty; return string.Empty;
} }
@@ -24,7 +24,7 @@ namespace ErsatzTV.Application.Playouts.Queries
GetFuturePlayoutItemsById request, GetFuturePlayoutItemsById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
DateTime now = DateTimeOffset.Now.UtcDateTime; DateTime now = DateTimeOffset.Now.UtcDateTime;
@@ -57,6 +57,10 @@ namespace ErsatzTV.Application.Playouts.Queries
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata) .ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions) .ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.Filter(i => i.PlayoutId == request.PlayoutId) .Filter(i => i.PlayoutId == request.PlayoutId)
.Filter(i => i.Finish >= now) .Filter(i => i.Finish >= now)
.Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None) .Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None)
@@ -26,7 +26,8 @@ namespace ErsatzTV.Application.Search.Queries
await GetIds(SearchIndex.EpisodeType, request.Query), await GetIds(SearchIndex.EpisodeType, request.Query),
await GetIds(SearchIndex.ArtistType, request.Query), await GetIds(SearchIndex.ArtistType, request.Query),
await GetIds(SearchIndex.MusicVideoType, request.Query), await GetIds(SearchIndex.MusicVideoType, request.Query),
await GetIds(SearchIndex.OtherVideoType, request.Query)); await GetIds(SearchIndex.OtherVideoType, request.Query),
await GetIds(SearchIndex.SongType, request.Query));
private Task<List<int>> GetIds(string type, string query) => private Task<List<int>> GetIds(string type, string query) =>
_searchIndex.Search($"type:{type} AND ({query})", 0, 0) _searchIndex.Search($"type:{type} AND ({query})", 0, 0)
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexSongs
(string Query, int PageNumber, int PageSize) : IRequest<SongCardResultsViewModel>;
}
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSongs,
SongCardResultsViewModel>
{
private readonly ISongRepository _songRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexSongsHandler(ISearchIndex searchIndex, ISongRepository songRepository)
{
_searchIndex = searchIndex;
_songRepository = songRepository;
}
public async Task<SongCardResultsViewModel> Handle(
QuerySearchIndexSongs request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<SongCardViewModel> items = await _songRepository
.GetSongsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new SongCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Search
List<int> EpisodeIds, List<int> EpisodeIds,
List<int> ArtistIds, List<int> ArtistIds,
List<int> MusicVideoIds, List<int> MusicVideoIds,
List<int> OtherVideoIds); List<int> OtherVideoIds,
List<int> SongIds);
} }
@@ -4,7 +4,7 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Runtime; using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -15,12 +15,12 @@ namespace ErsatzTV.Application.Streaming.Queries
{ {
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber> public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
{ {
private readonly FFmpegProcessService _ffmpegProcessService; private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IRuntimeInfo _runtimeInfo; private readonly IRuntimeInfo _runtimeInfo;
public GetConcatProcessByChannelNumberHandler( public GetConcatProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
FFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo)
: base(dbContextFactory) : base(dbContextFactory)
{ {
@@ -8,8 +8,9 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
@@ -31,15 +32,16 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
private readonly IArtistRepository _artistRepository; private readonly IArtistRepository _artistRepository;
private readonly FFmpegProcessService _ffmpegProcessService; private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo; private readonly IRuntimeInfo _runtimeInfo;
private readonly ISongVideoGenerator _songVideoGenerator;
public GetPlayoutItemProcessByChannelNumberHandler( public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
FFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService,
@@ -47,7 +49,8 @@ namespace ErsatzTV.Application.Streaming.Queries
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
IArtistRepository artistRepository, IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo,
ISongVideoGenerator songVideoGenerator)
: base(dbContextFactory) : base(dbContextFactory)
{ {
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
@@ -59,6 +62,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_artistRepository = artistRepository; _artistRepository = artistRepository;
_runtimeInfo = runtimeInfo; _runtimeInfo = runtimeInfo;
_songVideoGenerator = songVideoGenerator;
} }
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -94,6 +98,15 @@ namespace ErsatzTV.Application.Streaming.Queries
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions) .ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(ov => ov.Streams) .ThenInclude(ov => ov.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.ThenInclude(sm => sm.Artwork)
.ForChannelAndTime(channel.Id, now) .ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem())) .Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath); .BindT(ValidatePlayoutItemPath);
@@ -106,18 +119,13 @@ namespace ErsatzTV.Application.Streaming.Queries
return await maybePlayoutItem.Match( return await maybePlayoutItem.Match(
async playoutItemWithPath => async playoutItemWithPath =>
{ {
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem switch MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
};
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements string videoPath = playoutItemWithPath.Path;
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports) MediaVersion videoVersion = version;
.Map(result => result.IfNone(false));
string audioPath = playoutItemWithPath.Path;
MediaVersion audioVersion = version;
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId) .GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId)
@@ -125,12 +133,27 @@ namespace ErsatzTV.Application.Streaming.Queries
watermarkId => dbContext.ChannelWatermarks watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId)); .SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
{
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
maybeGlobalWatermark,
ffmpegPath);
}
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Process process = await _ffmpegProcessService.ForPlayoutItem( Process process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath, ffmpegPath,
saveReports, saveReports,
channel, channel,
version, videoVersion,
playoutItemWithPath.Path, audioVersion,
videoPath,
audioPath,
playoutItemWithPath.PlayoutItem.StartOffset, playoutItemWithPath.PlayoutItem.StartOffset,
playoutItemWithPath.PlayoutItem.FinishOffset, playoutItemWithPath.PlayoutItem.FinishOffset,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now, request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
@@ -170,7 +193,7 @@ namespace ErsatzTV.Application.Streaming.Queries
case UnableToLocatePlayoutItem: case UnableToLocatePlayoutItem:
if (channel.FFmpegProfile.Transcode) if (channel.FFmpegProfile.Transcode)
{ {
Process errorProcess = _ffmpegProcessService.ForError( Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
maybeDuration, maybeDuration,
@@ -189,7 +212,7 @@ namespace ErsatzTV.Application.Streaming.Queries
case PlayoutItemDoesNotExistOnDisk: case PlayoutItemDoesNotExistOnDisk:
if (channel.FFmpegProfile.Transcode) if (channel.FFmpegProfile.Transcode)
{ {
Process errorProcess = _ffmpegProcessService.ForError( Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
maybeDuration, maybeDuration,
@@ -208,7 +231,7 @@ namespace ErsatzTV.Application.Streaming.Queries
default: default:
if (channel.FFmpegProfile.Transcode) if (channel.FFmpegProfile.Transcode)
{ {
Process errorProcess = _ffmpegProcessService.ForError( Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
maybeDuration, maybeDuration,
@@ -271,14 +294,7 @@ namespace ErsatzTV.Application.Streaming.Queries
.MapT(pi => pi.StartOffset - now), .MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask()); () => Option<TimeSpan>.None.AsTask());
MediaVersion version = item switch MediaVersion version = item.GetHeadVersion();
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
version.MediaFiles = await dbContext.MediaFiles version.MediaFiles = await dbContext.MediaFiles
.AsNoTracking() .AsNoTracking()
@@ -331,14 +347,7 @@ namespace ErsatzTV.Application.Streaming.Queries
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem) private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
{ {
MediaVersion version = playoutItem.MediaItem switch MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
};
MediaFile file = version.MediaFiles.Head(); MediaFile file = version.MediaFiles.Head();
string path = file.Path; string path = file.Path;
@@ -1,106 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands
{
[Command("channel", Description = "Create or rename a channel")]
public class ChannelCommand : ICommand
{
private readonly ChannelsApi _channelsApi;
private readonly FFmpegProfileApi _ffmpegProfileApi;
private readonly ILogger<ChannelCommand> _logger;
public ChannelCommand(IConfiguration configuration, ILogger<ChannelCommand> logger)
{
_logger = logger;
_channelsApi = new ChannelsApi(configuration["ServerUrl"]);
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
}
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
public int Number { get; set; }
[CommandParameter(1, Name = "channel-name", Description = "The channel name")]
public string Name { get; set; }
[CommandParameter(2, Name = "streaming-mode", Description = "The streaming mode")]
public StreamingMode StreamingMode { get; set; }
[CommandOption("ffmpeg-profile", Description = "The ffmpeg profile name")]
public string FFmpegProfileName { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
Option<ChannelViewModel> maybeChannel = await _channelsApi.ApiChannelsGetAsync()
.Map(list => Optional(list.SingleOrDefault(c => c.Number == Number)));
FFmpegProfileViewModel ffmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
.Map(
list => Optional(list.SingleOrDefault(p => p.Name == FFmpegProfileName))
.IfNone(new FFmpegProfileViewModel { Id = 1 }));
await maybeChannel.Match(
channel => RenameChannel(channel, ffmpegProfile),
() => AddChannel(ffmpegProfile));
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize channel: {Message}", ex.Message);
}
}
private async ValueTask RenameChannel(ChannelViewModel existing, FFmpegProfileViewModel ffmpegProfile)
{
int newFFmpegProfileId = string.IsNullOrWhiteSpace(FFmpegProfileName)
? existing.FfmpegProfileId
: ffmpegProfile.Id;
if (existing.Name != Name || existing.FfmpegProfileId != newFFmpegProfileId ||
existing.StreamingMode != StreamingMode)
{
var updateChannel = new UpdateChannel(
existing.Id,
Name,
existing.Number,
newFFmpegProfileId,
existing.Logo,
StreamingMode);
await _channelsApi.ApiChannelsPatchAsync(updateChannel);
}
_logger.LogInformation(
"Successfully synchronized channel {ChannelNumber} - {ChannelName}",
Number,
Name);
}
private async ValueTask AddChannel(FFmpegProfileViewModel ffmpegProfile)
{
var createChannel = new CreateChannel(
Name,
Number,
ffmpegProfile.Id,
null,
StreamingMode);
await _channelsApi.ApiChannelsPostAsync(createChannel);
_logger.LogInformation(
"Successfully created channel {ChannelNumber} - {ChannelName}",
Number,
Name);
}
}
}
@@ -1,31 +0,0 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
namespace ErsatzTV.CommandLine.Commands
{
[Command("config", Description = "Configure ErsatzTV server url")]
public class ConfigCommand : ICommand
{
[CommandParameter(0, Name = "server-url", Description = "The url of the ErsatzTV server")]
public string ServerUrl { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
// TODO: validate URL
string configFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ersatztv");
string configFile = Path.Combine(configFolder, "cli.json");
var config = new Config { ServerUrl = ServerUrl };
string contents = JsonSerializer.Serialize(config);
await File.WriteAllTextAsync(configFile, contents);
}
}
}
@@ -1,151 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands
{
[Command("ffmpeg-profile", Description = "Synchronize an ffmpeg profile")]
public class FFmpegProfileCommand : ICommand
{
private readonly FFmpegProfileApi _ffmpegProfileApi;
private readonly ILogger<FFmpegProfileCommand> _logger;
public FFmpegProfileCommand(IConfiguration configuration, ILogger<FFmpegProfileCommand> logger)
{
_logger = logger;
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
}
[CommandParameter(0, Name = "profile-name", Description = "The ffmpeg profile name")]
public string Name { get; set; }
[CommandOption("thread-count", Description = "The number of threads")]
public int ThreadCount { get; set; } = 0;
[CommandOption("transcode", Description = "Whether to transcode all media")]
public bool Transcode { get; set; } = true;
// public int ResolutionId { get; set; } = resolution.Id;
// Resolution { get; set; } = resolution;
[CommandOption("resolution", Description = "The resolution")]
public DesiredResolution Resolution { get; set; } = DesiredResolution.W1920H1080;
[CommandOption("video-codec", Description = "The video codec")]
public string VideoCodec { get; set; } = "libx264";
[CommandOption("audio-codec", Description = "The audio codec")]
public string AudioCodec { get; set; } = "ac3";
[CommandOption("video-bitrate", Description = "The video bitrate in kBit/s")]
public int VideoBitrate { get; set; } = 2000;
[CommandOption("video-buffer-size", Description = "The video buffer size in kBit")]
public int VideoBufferSize { get; set; } = 2000;
[CommandOption("audio-bitrate", Description = "The audio bitrate in kBit/s")]
public int AudioBitrate { get; set; } = 192;
[CommandOption("audio-buffer-size", Description = "The audio buffer size in kBits")]
public int AudioBufferSize { get; set; } = 50;
[CommandOption("audio-volume", Description = "The audio volume as a whole number percent")]
public int AudioVolume { get; set; } = 100;
[CommandOption("audio-channels", Description = "The number of audio channels")]
public int AudioChannels { get; set; } = 2;
[CommandOption("audio-sample-rate", Description = "The audio sample rate in kHz")]
public int AudioSampleRate { get; set; } = 48;
[CommandOption("normalize-resolution", Description = "Whether to normalize the resolution of all media")]
public bool NormalizeResolution { get; set; } = true;
[CommandOption("normalize-video-codec", Description = "Whether to normalize the video codec of all media")]
public bool NormalizeVideoCodec { get; set; } = true;
[CommandOption("normalize-audio-codec", Description = "Whether to normalize the audio codec of all media")]
public bool NormalizeAudioCodec { get; set; } = true;
[CommandOption(
"normalize-audio",
Description = "Whether to normalize audio channels and sample rate of all media")]
public bool NormalizeAudio { get; set; } = true;
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
Option<FFmpegProfileViewModel> maybeFFmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
.Map(list => Optional(list.SingleOrDefault(p => p.Name == Name)));
await maybeFFmpegProfile.Match(UpdateProfile, AddProfile);
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize ffmpeg profile: {Message}", ex.Message);
}
}
private async ValueTask UpdateProfile(FFmpegProfileViewModel existing)
{
var updateFFmpegProfile = new UpdateFFmpegProfile(
existing.Id,
Name,
ThreadCount,
Transcode,
(int) Resolution,
NormalizeResolution,
VideoCodec,
NormalizeVideoCodec,
VideoBitrate,
VideoBufferSize,
AudioCodec,
NormalizeAudioCodec,
AudioBitrate,
AudioBufferSize,
AudioVolume,
AudioChannels,
AudioSampleRate,
NormalizeAudio);
await _ffmpegProfileApi.ApiFfmpegProfilesPatchAsync(updateFFmpegProfile);
_logger.LogInformation("Successfully synchronized ffmpeg profile {ProfileName}", Name);
}
private async ValueTask AddProfile()
{
var createFFmpegProfile = new CreateFFmpegProfile(
Name,
ThreadCount,
Transcode,
(int) Resolution,
NormalizeResolution,
VideoCodec,
NormalizeVideoCodec,
VideoBitrate,
VideoBufferSize,
AudioCodec,
NormalizeAudioCodec,
AudioBitrate,
AudioBufferSize,
AudioVolume,
AudioChannels,
AudioSampleRate,
NormalizeAudio);
await _ffmpegProfileApi.ApiFfmpegProfilesPostAsync(createFFmpegProfile);
_logger.LogInformation("Successfully created ffmpeg profile {ProfileName}", Name);
}
}
}
@@ -1,86 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.MediaCollections
{
[Command("collection clear", Description = "Removes all items from a media collection")]
public class MediaCollectionClearCommand : ICommand
{
private readonly ILogger<MediaCollectionClearCommand> _logger;
private readonly string _serverUrl;
public MediaCollectionClearCommand(IConfiguration configuration, ILogger<MediaCollectionClearCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
public string Name { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await ClearMediaCollection(cancellationToken);
result.Match(
_ => _logger.LogInformation("Successfully cleared media collection {MediaCollection}", Name),
error => _logger.LogError(
"Unable to clear media collection: {Error}",
error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to clear media collection: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> ClearMediaCollection(CancellationToken cancellationToken) =>
await EnsureMediaCollectionExists(cancellationToken)
.BindAsync(mediaCollectionId => ClearMediaCollectionImpl(mediaCollectionId, cancellationToken));
private async Task<Either<Error, int>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
Option<MediaCollectionViewModel> maybeExisting =
(await mediaCollectionsApi.ApiMediaCollectionsGetAsync(cancellationToken))
.SingleOrDefault(mc => mc.Name == Name);
return await maybeExisting.MatchAsync(
existing => existing.Id,
async () =>
{
var data = new CreateSimpleMediaCollection(Name);
MediaCollectionViewModel result =
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
return result.Id;
});
}
private async Task<Either<Error, Unit>> ClearMediaCollectionImpl(
int mediaCollectionId,
CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
await mediaCollectionsApi.ApiMediaCollectionsIdItemsPutAsync(
mediaCollectionId,
new List<int>(),
cancellationToken);
return unit;
}
}
}
@@ -1,72 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.MediaCollections
{
[Command("collection create", Description = "Creates a new media collection")]
public class MediaCollectionCreateCommand : ICommand
{
private readonly ILogger<MediaCollectionCreateCommand> _logger;
private readonly string _serverUrl;
public MediaCollectionCreateCommand(IConfiguration configuration, ILogger<MediaCollectionCreateCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
public string Name { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await CreateMediaCollection(cancellationToken);
result.IfLeft(error => _logger.LogError("Unable to create media collection: {Error}", error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to create media collection: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> CreateMediaCollection(CancellationToken cancellationToken) =>
await EnsureMediaCollectionExists(cancellationToken);
private async Task<Either<Error, Unit>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
bool needToAdd = await mediaCollectionsApi
.ApiMediaCollectionsGetAsync(cancellationToken)
.Map(list => list.All(mc => mc.Name != Name));
if (needToAdd)
{
var data = new CreateSimpleMediaCollection(Name);
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
_logger.LogInformation("Successfully created media collection {MediaCollection}", Name);
}
else
{
_logger.LogInformation("Media collection {MediaCollection} is already present", Name);
}
return unit;
}
}
}
@@ -1,107 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.CommandLine.Commands
{
[Command("playout build", Description = "Builds a playout with the requested channel and schedule")]
public class PlayoutCommand : ICommand
{
private readonly ILogger<PlayoutCommand> _logger;
private readonly string _serverUrl;
public PlayoutCommand(IConfiguration configuration, ILogger<PlayoutCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
public int ChannelNumber { get; set; }
[CommandParameter(1, Name = "schedule-name", Description = "The schedule name")]
public string ScheduleName { get; set; }
// [Option("--type <type>")]
// [Required]
// public ProgramSchedulePlayoutType PlayoutType { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
var channelsApi = new ChannelsApi(_serverUrl);
Option<ChannelViewModel> maybeChannel = await channelsApi.ApiChannelsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(c => c.Number == ChannelNumber));
await maybeChannel.Match(
channel => BuildPlayout(cancellationToken, channel),
() =>
{
_logger.LogError("Unable to locate channel number {ChannelNumber}", ChannelNumber);
return ValueTask.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to build playout: {Error}", ex.Message);
}
}
private async ValueTask BuildPlayout(CancellationToken cancellationToken, ChannelViewModel channel)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
Option<ProgramScheduleViewModel> maybeSchedule = await programScheduleApi
.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(s => s.Name == ScheduleName));
await maybeSchedule.Match(
schedule => SynchronizePlayoutAsync(channel.Id, schedule.Id, cancellationToken),
() =>
{
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
return ValueTask.CompletedTask;
});
}
private async ValueTask SynchronizePlayoutAsync(
int channelId,
int scheduleId,
CancellationToken cancellationToken)
{
var playoutApi = new PlayoutApi(_serverUrl);
Option<PlayoutViewModel> maybeExisting = await playoutApi.ApiPlayoutsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(p => p.Channel.Id == channelId));
await maybeExisting.Match(
existing =>
{
var data = new UpdatePlayout(existing.Id, channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
if (existing.Channel.Id != data.ChannelId ||
existing.ProgramSchedule.Id != data.ProgramScheduleId ||
existing.ProgramSchedulePlayoutType != data.ProgramSchedulePlayoutType)
{
return playoutApi.ApiPlayoutsPatchAsync(data, cancellationToken);
}
return Task.CompletedTask;
},
() =>
{
var data = new CreatePlayout(channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
return playoutApi.ApiPlayoutsPostAsync(data, cancellationToken);
});
_logger.LogInformation("Successfully built playout for schedule {Schedule}", ScheduleName);
}
}
}
@@ -1,140 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.CommandLine.Commands.Schedules
{
[Command("schedule add-item", Description = "Adds an item to the end of a schedule")]
public class ScheduleAddItemCommand : ICommand
{
private readonly ILogger<ScheduleAddItemCommand> _logger;
private readonly string _serverUrl;
public ScheduleAddItemCommand(IConfiguration configuration, ILogger<ScheduleAddItemCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
public string ScheduleName { get; set; }
[CommandParameter(1, Name = "collection-name", Description = "The media collection name")]
public string CollectionName { get; set; }
// [CommandParameter(2, Description = "The collection playback order")]
// public PlaybackOrder Order { get; set; }
[CommandOption("start-type", 's', Description = "The playout start type")]
public StartType StartType { get; set; } = StartType.Dynamic;
[CommandOption("start-time", 't', Description = "The playout start time (of day)")]
public string StartTime { get; set; } = null;
[CommandOption("playout-mode", 'm', Description = "The playout mode")]
public PlayoutMode PlayoutMode { get; set; } = PlayoutMode.Flood;
[CommandOption(
"multiple-count",
'c',
Description = "How many items to play from the collection (for Multiple playout mode)")]
public int? MultipleCount { get; set; } = null;
[CommandOption(
"playout-duration",
'd',
Description = "How long to play items from the collection (for Duration playout mode)")]
public string PlayoutDuration { get; set; } = null;
[CommandOption(
"offline-tail",
'o',
Description =
"Whether to remain offline for the entire duration, or to start the next item immediately (for Duration playout mode)")]
public bool? OfflineTail { get; set; } = null;
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Option<ProgramScheduleViewModel> maybeSchedule = await GetSchedule(cancellationToken);
await maybeSchedule.Match(
programSchedule => AddItemToSchedule(cancellationToken, programSchedule),
() =>
{
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
return ValueTask.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to add item to schedule: {Error}", ex.Message);
}
}
private async ValueTask AddItemToSchedule(
CancellationToken cancellationToken,
ProgramScheduleViewModel programSchedule)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
Option<MediaCollectionViewModel> maybeMediaCollection = await mediaCollectionsApi
.ApiMediaCollectionsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(mc => mc.Name == CollectionName));
await maybeMediaCollection.Match(
collection =>
AddScheduleItem(programSchedule.Id, collection.Id, cancellationToken),
() =>
{
_logger.LogError(
"Unable to locate collection {Collection}",
CollectionName);
return Task.CompletedTask;
});
}
private async Task<Option<ProgramScheduleViewModel>> GetSchedule(CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
return await programScheduleApi.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(schedule => schedule.Name == ScheduleName));
}
private async Task AddScheduleItem(
int programScheduleId,
int mediaCollectionId,
CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
var request = new AddProgramScheduleItem
{
ProgramScheduleId = programScheduleId,
StartType = StartType,
StartTime = StartTime,
PlayoutMode = PlayoutMode,
MediaCollectionId = mediaCollectionId,
PlayoutDuration = PlayoutDuration,
MultipleCount = MultipleCount,
OfflineTail = OfflineTail
};
await programScheduleApi.ApiSchedulesItemsAddPostAsync(request, cancellationToken);
_logger.LogInformation(
"Collection {Collection} has been added to schedule {Schedule}",
CollectionName,
ScheduleName);
}
}
}
@@ -1,86 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.Schedules
{
[Command("schedule create", Description = "Creates a new schedule")]
public class ScheduleCreateCommand : ICommand
{
private readonly ILogger<ScheduleCreateCommand> _logger;
private readonly string _serverUrl;
public ScheduleCreateCommand(IConfiguration configuration, ILogger<ScheduleCreateCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
public string Name { get; set; }
[CommandParameter(1, Name = "playback-order", Description = "The collection playback order")]
public PlaybackOrder Order { get; set; }
[CommandOption("reset", Description = "Resets the schedule to contain no items")]
public bool Reset { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await EnsureScheduleExistsAsync(cancellationToken);
result.IfLeft(error => _logger.LogError("Unable to create schedule: {Error}", error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to create schedule: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> EnsureScheduleExistsAsync(CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
Option<ProgramScheduleViewModel> maybeExisting = await programScheduleApi
.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(schedule => schedule.Name == Name));
await maybeExisting.Match(
existing =>
{
// TODO: update playback order if changed?
_logger.LogInformation("Schedule {Schedule} is already present", Name);
if (Reset)
{
return programScheduleApi
.ApiSchedulesProgramScheduleIdItemsDeleteAsync(existing.Id, cancellationToken)
.Iter(_ => _logger.LogInformation("Successfully reset schedule {Schedule}", Name));
}
return Task.CompletedTask;
},
() =>
{
var data = new CreateProgramSchedule(Name, Order);
return programScheduleApi.ApiSchedulesPostAsync(data, cancellationToken)
.Iter(_ => _logger.LogInformation("Successfully created schedule {Schedule}", Name));
});
return unit;
}
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace ErsatzTV.CommandLine
{
public class Config
{
public string ServerUrl { get; set; }
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace ErsatzTV.CommandLine
{
public enum DesiredResolution
{
W720H480 = 1,
W1280H720 = 2,
W1920H1080 = 3,
W3840H2160 = 4
}
}
@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<AssemblyName>ersatztv-cli</AssemblyName>
<LangVersion>9</LangVersion>
<PackageVersion>0.0.1</PackageVersion>
<AssemblyVersion>0.0.1</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="1.6.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
<PackageReference Include="RestSharp" Version="106.11.7" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\generated\ErsatzTV.Api.Sdk\src\ErsatzTV.Api.Sdk\ErsatzTV.Api.Sdk.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Hosting.Abstractions, Version=5.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<HintPath>..\..\..\..\..\..\usr\share\dotnet\packs\Microsoft.AspNetCore.App.Ref\5.0.0\ref\net5.0\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
-75
View File
@@ -1,75 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using Serilog.Events;
namespace ErsatzTV.CommandLine
{
public class Program
{
public static async Task<int> Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateLogger();
IHost host = CreateHostBuilder(args).Build();
try
{
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseTypeActivator(host.Services.GetService)
.Build()
.RunAsync(args);
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
return 1;
}
finally
{
Log.CloseAndFlush();
}
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices(
(_, services) =>
{
services.AddSingleton<IConsole, SystemConsole>();
IEnumerable<Type> typesThatImplementICommand = typeof(Program).Assembly.GetTypes()
.Where(x => typeof(ICommand).IsAssignableFrom(x))
.Where(x => !x.IsAbstract);
foreach (Type t in typesThatImplementICommand)
{
services.AddTransient(t);
}
})
.ConfigureAppConfiguration(
(_, configuration) =>
{
configuration.Sources.Clear();
string configFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ersatztv");
configuration.SetBasePath(configFolder);
configuration.AddJsonFile("cli.json", true, true);
})
.UseSerilog()
.UseConsoleLifetime();
}
}
@@ -1,24 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.2.0" /> <PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" /> <PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" /> <PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.1.0" />
<PackageReference Include="Serilog" Version="2.10.0" /> <PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" /> <PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" /> <PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
</ItemGroup> </ItemGroup>
@@ -19,7 +19,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ {
var builder = new FFmpegComplexFilterBuilder(); var builder = new FFmpegComplexFilterBuilder();
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsNone.Should().BeTrue(); result.IsNone.Should().BeTrue();
} }
@@ -27,17 +27,38 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test] [Test]
public void Should_Return_Audio_Filter_With_AudioDuration() public void Should_Return_Audio_Filter_With_AudioDuration()
{ {
var duration = TimeSpan.FromMinutes(54); var duration = TimeSpan.FromMilliseconds(1000.1);
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration); .WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
filter => filter =>
{ {
filter.ComplexFilter.Should().Be($"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a]"); filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("0:0");
});
}
[Test]
// this needs to be a culture where '.' is a group separator
[SetCulture("it-IT")]
public void Should_Return_Audio_Filter_With_AudioDuration_Decimal()
{
var duration = TimeSpan.FromMilliseconds(1000.1);
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
filter.AudioLabel.Should().Be("[a]"); filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("0:0"); filter.VideoLabel.Should().Be("0:0");
}); });
@@ -51,7 +72,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
.WithAlignedAudio(duration) .WithAlignedAudio(duration)
.WithDeinterlace(true); .WithDeinterlace(true);
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -102,7 +123,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -253,11 +274,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
HorizontalMarginPercent = 7, HorizontalMarginPercent = 7,
VerticalMarginPercent = 5 VerticalMarginPercent = 5
}), }),
new Resolution { Width = 1920, Height = 1080 }) new Resolution { Width = 1920, Height = 1080 },
None)
.WithDeinterlace(deinterlace) .WithDeinterlace(deinterlace)
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None); .WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -328,7 +350,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -346,7 +368,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]yadif_cuda,scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]", "[0:0]yadif_cuda,scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
@@ -358,13 +380,13 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
true, true,
"[0:0]yadif_cuda,scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]yadif_cuda,scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
false, false,
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]", "[0:0]scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
@@ -376,7 +398,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false, false,
true, true,
true, true,
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
public void Should_Return_NVENC_Video_Filter( public void Should_Return_NVENC_Video_Filter(
bool deinterlace, bool deinterlace,
@@ -399,7 +421,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -521,7 +543,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 }); builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
} }
Option<FFmpegComplexFilter> result = builder.Build(0, 1); Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
result.IsSome.Should().BeTrue(); result.IsSome.Should().BeTrue();
result.IfSome( result.IfSome(
@@ -12,7 +12,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime() public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -26,13 +26,13 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input); TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input);
result.PlaylistStart.Should().Be(start); result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137); result.Sequence.Should().Be(1137);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -49,14 +49,14 @@ live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts live001139.ts
"); "));
} }
[Test] [Test]
public void HlsPlaylistFilter_ShouldLimitSegments() public void HlsPlaylistFilter_ShouldLimitSegments()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -70,13 +70,13 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2); TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2);
result.PlaylistStart.Should().Be(start); result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137); result.Sequence.Should().Be(1137);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -90,14 +90,14 @@ live001137.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts live001138.ts
"); "));
} }
[Test] [Test]
public void HlsPlaylistFilter_ShouldAddDiscontinuity() public void HlsPlaylistFilter_ShouldAddDiscontinuity()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -111,7 +111,7 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist( TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(
start, start,
@@ -122,7 +122,7 @@ live001139.ts".Split(Environment.NewLine);
result.PlaylistStart.Should().Be(start); result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137); result.Sequence.Should().Be(1137);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -140,14 +140,14 @@ live001138.ts
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts live001139.ts
#EXT-X-DISCONTINUITY #EXT-X-DISCONTINUITY
"); "));
} }
[Test] [Test]
public void HlsPlaylistFilter_ShouldFilterOldSegments() public void HlsPlaylistFilter_ShouldFilterOldSegments()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -161,13 +161,13 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input); TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8)); result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139); result.Sequence.Should().Be(1139);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -178,14 +178,14 @@ live001139.ts".Split(Environment.NewLine);
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts live001139.ts
"); "));
} }
[Test] [Test]
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity() public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
{ {
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5)); var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U string[] input = NormalizeLineEndings(@"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137 #EXT-X-MEDIA-SEQUENCE:1137
@@ -200,13 +200,13 @@ live001137.ts
live001138.ts live001138.ts
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine); live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input); TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8)); result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139); result.Sequence.Should().Be(1139);
result.Playlist.Should().Be( result.Playlist.Should().Be(NormalizeLineEndings(
@"#EXTM3U @"#EXTM3U
#EXT-X-VERSION:6 #EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4 #EXT-X-TARGETDURATION:4
@@ -217,7 +217,15 @@ live001139.ts".Split(Environment.NewLine);
#EXTINF:4.000000, #EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500 #EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts live001139.ts
"); "));
}
private static string NormalizeLineEndings(string str)
{
return str
.Replace("\r\n", "\n")
.Replace("\r", "\n")
.Replace("\n", Environment.NewLine);
} }
} }
} }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
@@ -138,6 +139,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
new FFmpegPlaybackSettingsCalculator(), new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(), new FakeStreamSelector(),
new Mock<IImageCache>().Object, new Mock<IImageCache>().Object,
new Mock<ITempFilePool>().Object,
new Mock<ILogger<FFmpegProcessService>>().Object); new Mock<ILogger<FFmpegProcessService>>().Object);
MediaVersion v = new MediaVersion(); MediaVersion v = new MediaVersion();
@@ -183,6 +185,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode = StreamingMode.TransportStream StreamingMode = StreamingMode.TransportStream
}, },
v, v,
v,
file,
file, file,
now, now,
now + TimeSpan.FromSeconds(5), now + TimeSpan.FromSeconds(5),
@@ -237,7 +241,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
return string.Empty; return string.Empty;
} }
using var sha = new System.Security.Cryptography.SHA256Managed(); using var sha = SHA256.Create();
byte[] textData = System.Text.Encoding.UTF8.GetBytes(text); byte[] textData = System.Text.Encoding.UTF8.GetBytes(text);
byte[] hash = sha.ComputeHash(textData); byte[] hash = sha.ComputeHash(textData);
return BitConverter.ToString(hash).Replace("-", string.Empty); return BitConverter.ToString(hash).Replace("-", string.Empty);
@@ -25,7 +25,7 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ILogger<LocalStatisticsProvider>>().Object); new Mock<ILogger<LocalStatisticsProvider>>().Object);
var input = new LocalStatisticsProvider.FFprobe( var input = new LocalStatisticsProvider.FFprobe(
new LocalStatisticsProvider.FFprobeFormat("123.45"), new LocalStatisticsProvider.FFprobeFormat("123.45", null),
new List<LocalStatisticsProvider.FFprobeStream>(), new List<LocalStatisticsProvider.FFprobeStream>(),
new List<LocalStatisticsProvider.FFprobeChapter>()); new List<LocalStatisticsProvider.FFprobeChapter>());
@@ -6,6 +6,8 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -601,6 +603,8 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ISearchRepository>().Object, new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object, new Mock<ILibraryRepository>().Object,
new Mock<IMediator>().Object, new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object new Mock<ILogger<MovieFolderScanner>>().Object
); );
} }
@@ -76,6 +76,72 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[2].GuideFinish.HasValue.Should().BeTrue(); playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
} }
[Test]
public void Should_Fill_Exact_Duration_CustomTitle()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
var scheduleItem = new ProgramScheduleItemDuration
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.Chronological,
CustomTitle = "Custom Title"
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var scheduler = new PlayoutModeSchedulerDuration(new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
StartState,
CollectionEnumerators(scheduleItem, enumerator),
scheduleItem,
NextScheduleItem,
HardStop);
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemIndex.Should().Be(1);
enumerator.State.Index.Should().Be(1);
playoutItems.Count.Should().Be(3);
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
playoutItems[0].CustomTitle.Should().Be("Custom Title");
playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(1);
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
playoutItems[1].CustomTitle.Should().Be("Custom Title");
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(1);
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
playoutItems[2].CustomTitle.Should().Be("Custom Title");
}
[Test] [Test]
public void Should_Not_Have_Gap_Duration_Tail_Mode_None() public void Should_Not_Have_Gap_Duration_Tail_Mode_None()
{ {
@@ -73,6 +73,75 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[1].GuideGroup.Should().Be(2); playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None); playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(3);
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
}
[Test]
public void Should_Fill_Exactly_To_Next_Schedule_Item_Flood()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
var scheduleItem = new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var sortedScheduleItems = new List<ProgramScheduleItem>
{
scheduleItem,
// this caused trouble with the peek logic and the IsFlood flag
new ProgramScheduleItemFlood
{
StartTime = TimeSpan.FromHours(3),
}
};
var scheduler = new PlayoutModeSchedulerFlood(sortedScheduleItems, new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
StartState,
CollectionEnumerators(scheduleItem, enumerator),
scheduleItem,
NextScheduleItem,
HardStop);
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(4);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemIndex.Should().Be(1);
enumerator.State.Index.Should().Be(1);
playoutItems.Count.Should().Be(3);
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
playoutItems[1].MediaItemId.Should().Be(2);
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
playoutItems[1].GuideGroup.Should().Be(2);
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
playoutItems[2].MediaItemId.Should().Be(1); playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2)); playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
playoutItems[2].GuideGroup.Should().Be(3); playoutItems[2].GuideGroup.Should().Be(3);
@@ -709,7 +778,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne
{ {
StartTime = TimeSpan.FromHours(3) StartTime = TimeSpan.FromHours(3),
}; };
} }
} }
@@ -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);
}
}
}
@@ -5,6 +5,7 @@
Movies = 1, Movies = 1,
Shows = 2, Shows = 2,
MusicVideos = 3, MusicVideos = 3,
OtherVideos = 4 OtherVideos = 4,
Songs = 5
} }
} }
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class BackgroundImageMediaVersion : MediaVersion
{
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class CoverArtMediaVersion : MediaVersion
{
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class FallbackMediaVersion : MediaVersion
{
}
}
@@ -12,6 +12,7 @@
public string Title { get; set; } public string Title { get; set; }
public bool Default { get; set; } public bool Default { get; set; }
public bool Forced { get; set; } public bool Forced { get; set; }
public bool AttachedPic { get; set; }
public string PixelFormat { get; set; } public string PixelFormat { get; set; }
public int BitsPerRawSample { get; set; } public int BitsPerRawSample { get; set; }
public int MediaVersionId { get; set; } public int MediaVersionId { get; set; }
+10
View File
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Song : MediaItem
{
public List<SongMetadata> SongMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
}
+4
View File
@@ -6,6 +6,10 @@ namespace ErsatzTV.Core.Domain
{ {
public int Id { get; set; } public int Id { get; set; }
public string Path { get; set; } public string Path { get; set; }
public string SourcePath { get; set; }
public string BlurHash43 { get; set; }
public string BlurHash54 { get; set; }
public string BlurHash64 { get; set; }
public ArtworkKind ArtworkKind { get; set; } public ArtworkKind ArtworkKind { get; set; }
public DateTime DateAdded { get; set; } public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; } public DateTime DateUpdated { get; set; }
@@ -4,6 +4,7 @@
{ {
Fallback = 0, Fallback = 0,
Sidecar = 1, Sidecar = 1,
External = 2 External = 2,
Embedded = 3
} }
} }
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain
{
public class SongMetadata : Metadata
{
public string Album { get; set; }
public string Artist { get; set; }
public string Date { get; set; }
public string Track { get; set; }
public int SongId { get; set; }
public Song Song { get; set; }
}
}
+5 -5
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType> <DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
@@ -10,15 +10,15 @@
<PackageReference Include="Flurl" Version="3.0.2" /> <PackageReference Include="Flurl" Version="3.0.2" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" /> <PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="MediatR" Version="9.0.0" /> <PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Serilog" Version="2.10.0" /> <PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,19 @@
using System;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Extensions
{
public static class MediaItemExtensions
{
public static MediaVersion GetHeadVersion(this MediaItem mediaItem) =>
mediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
Song s => s.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
}
}
@@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -20,8 +22,11 @@ namespace ErsatzTV.Core.FFmpeg
private IDisplaySize _resolution; private IDisplaySize _resolution;
private Option<IDisplaySize> _scaleToSize = None; private Option<IDisplaySize> _scaleToSize = None;
private Option<ChannelWatermark> _watermark; private Option<ChannelWatermark> _watermark;
private Option<int> _watermarkIndex;
private string _pixelFormat; private string _pixelFormat;
private string _videoEncoder; private string _videoEncoder;
private Option<string> _subtitle;
private bool _boxBlur;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind) public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{ {
@@ -59,22 +64,64 @@ namespace ErsatzTV.Core.FFmpeg
return this; return this;
} }
public FFmpegComplexFilterBuilder WithInputCodec(string codec) public FFmpegComplexFilterBuilder WithInputCodec(Option<string> maybeCodec)
{ {
_inputCodec = codec; foreach (string codec in maybeCodec)
{
_inputCodec = codec;
}
return this; return this;
} }
public FFmpegComplexFilterBuilder WithInputPixelFormat(string pixelFormat) public FFmpegComplexFilterBuilder WithInputPixelFormat(Option<string> maybePixelFormat)
{ {
_pixelFormat = pixelFormat; foreach (string pixelFormat in maybePixelFormat)
{
_pixelFormat = pixelFormat;
}
return this; return this;
} }
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution) public FFmpegComplexFilterBuilder WithWatermark(
Option<ChannelWatermark> watermark,
IDisplaySize resolution,
Option<int> watermarkIndex)
{ {
_watermark = watermark; _watermark = watermark;
_resolution = resolution; _resolution = resolution;
_watermarkIndex = watermarkIndex;
return this;
}
public FFmpegComplexFilterBuilder WithBoxBlur(bool boxBlur)
{
_boxBlur = boxBlur;
return this;
}
public FFmpegComplexFilterBuilder WithSubtitleFile(Option<string> subtitleFile)
{
foreach (string file in subtitleFile)
{
string effectiveFile = file;
string fontsDir = FileSystemLayout.ResourcesCacheFolder;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
fontsDir = fontsDir
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
effectiveFile = effectiveFile
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
}
_subtitle = $"subtitles={effectiveFile}:fontsdir={fontsDir}";
}
return this; return this;
} }
@@ -84,19 +131,19 @@ namespace ErsatzTV.Core.FFmpeg
return this; return this;
} }
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex) public Option<FFmpegComplexFilter> Build(bool videoOnly, int videoInput, int videoStreamIndex, int audioInput, Option<int> audioStreamIndex, bool isSong)
{ {
var complexFilter = new StringBuilder(); var complexFilter = new StringBuilder();
var videoLabel = $"0:{videoStreamIndex}"; var videoLabel = $"{videoInput}:{(isSong ? "v" : videoStreamIndex.ToString())}";
string audioLabel = audioStreamIndex.Match(index => $"0:{index}", () => "0:a"); string audioLabel = audioStreamIndex.Match(index => $"{audioInput}:{index}", () => "0:a");
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None); HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch bool isHardwareDecode = acceleration switch
{ {
HardwareAccelerationKind.Vaapi => _inputCodec != "mpeg4", HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4",
HardwareAccelerationKind.Nvenc => true, HardwareAccelerationKind.Nvenc => !isSong,
HardwareAccelerationKind.Qsv => true, HardwareAccelerationKind.Qsv => !isSong,
_ => false _ => false
}; };
@@ -104,20 +151,52 @@ namespace ErsatzTV.Core.FFmpeg
var videoFilterQueue = new List<string>(); var videoFilterQueue = new List<string>();
string watermarkPreprocess = string.Empty; string watermarkPreprocess = string.Empty;
string watermarkOverlay = string.Empty; string watermarkOverlay = string.Empty;
if (_normalizeLoudness) if (_normalizeLoudness)
{ {
audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11"); audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11");
} }
_audioDuration.IfSome( _audioDuration.IfSome(
audioDuration => audioFilterQueue.Add($"apad=whole_dur={audioDuration.TotalMilliseconds}ms")); audioDuration =>
{
var durationString = audioDuration.TotalMilliseconds.ToString(NumberFormatInfo.InvariantInfo);
audioFilterQueue.Add($"apad=whole_dur={durationString}ms");
});
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode && bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome); (_deinterlace || _scaleToSize.IsSome);
if (usesHardwareFilters)
if (isSong)
{ {
videoFilterQueue.Add("hwupload"); switch (acceleration)
{
case HardwareAccelerationKind.Qsv:
videoFilterQueue.Add("format=nv12");
break;
case HardwareAccelerationKind.Vaapi:
videoFilterQueue.Add("format=nv12|vaapi");
break;
default:
videoFilterQueue.Add("format=yuv420p");
break;
}
}
switch (usesHardwareFilters || isSong, acceleration)
{
case (true, HardwareAccelerationKind.Nvenc):
videoFilterQueue.Add("hwupload_cuda");
break;
case (true, HardwareAccelerationKind.Qsv):
videoFilterQueue.Add("hwupload=extra_hw_frames=64");
break;
case (true, HardwareAccelerationKind.Vaapi):
videoFilterQueue.Add("hwupload");
break;
case (true, _) when usesHardwareFilters:
videoFilterQueue.Add("hwupload");
break;
} }
if (_deinterlace) if (_deinterlace)
@@ -158,8 +237,9 @@ namespace ErsatzTV.Core.FFmpeg
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}", HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" => HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" =>
$"hwupload_cuda,scale_cuda={size.Width}:{size.Height}", $"hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}", HardwareAccelerationKind.Nvenc => $"scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}", HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
_ when videoOnly => $"scale={size.Width}:{size.Height}:force_original_aspect_ratio=increase,crop={size.Width}:{size.Height}",
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear" _ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
}; };
@@ -182,16 +262,28 @@ namespace ErsatzTV.Core.FFmpeg
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi", HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" => HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
"format=p010le,format=nv12", "format=p010le,format=nv12",
HardwareAccelerationKind.Qsv when isSong => "format=nv12,format=yuv420p",
_ when isSong => "format=yuv420p",
_ => "format=nv12" _ => "format=nv12"
}; };
videoFilterQueue.Add(format); videoFilterQueue.Add(format);
} }
if (scaleOrPad) if (scaleOrPad && _boxBlur == false)
{ {
videoFilterQueue.Add("setsar=1"); videoFilterQueue.Add("setsar=1");
} }
if (_boxBlur)
{
videoFilterQueue.Add("boxblur=40");
}
if (videoOnly)
{
videoFilterQueue.Add("deband");
}
foreach (ChannelWatermark watermark in _watermark) foreach (ChannelWatermark watermark in _watermark)
{ {
string enable = watermark.Mode == ChannelWatermarkMode.Intermittent string enable = watermark.Mode == ChannelWatermarkMode.Intermittent
@@ -237,6 +329,11 @@ namespace ErsatzTV.Core.FFmpeg
} }
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2")); _padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
foreach (string subtitle in _subtitle)
{
videoFilterQueue.Add(subtitle);
}
string outputPixelFormat = null; string outputPixelFormat = null;
@@ -301,7 +398,12 @@ namespace ErsatzTV.Core.FFmpeg
complexFilter.Append("[vt];"); complexFilter.Append("[vt];");
} }
var watermarkLabel = "[1:v]"; var watermarkLabel = $"[{audioInput+1}:v]";
foreach (int index in _watermarkIndex)
{
watermarkLabel = $"[{audioInput+1}:{index}]";
}
if (!string.IsNullOrWhiteSpace(watermarkPreprocess)) if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
{ {
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];"); complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");
@@ -315,10 +417,21 @@ namespace ErsatzTV.Core.FFmpeg
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None) if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
{ {
complexFilter.Append(",hwupload"); switch (isSong, acceleration)
{
case (true, HardwareAccelerationKind.Nvenc):
complexFilter.Append(",hwupload_cuda");
break;
case (_, HardwareAccelerationKind.Qsv):
complexFilter.Append(",format=yuv420p,hwupload=extra_hw_frames=64");
break;
default:
complexFilter.Append(",hwupload");
break;
}
} }
} }
videoLabel = "[v]"; videoLabel = "[v]";
complexFilter.Append(videoLabel); complexFilter.Append(videoLabel);
} }
@@ -45,8 +45,8 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegPlaybackSettings CalculateSettings( public FFmpegPlaybackSettings CalculateSettings(
StreamingMode streamingMode, StreamingMode streamingMode,
FFmpegProfile ffmpegProfile, FFmpegProfile ffmpegProfile,
MediaVersion version, MediaVersion videoVersion,
MediaStream videoStream, Option<MediaStream> videoStream,
Option<MediaStream> audioStream, Option<MediaStream> audioStream,
DateTimeOffset start, DateTimeOffset start,
DateTimeOffset now, DateTimeOffset now,
@@ -76,10 +76,10 @@ namespace ErsatzTV.Core.FFmpeg
case StreamingMode.TransportStream: case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration; result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
if (NeedToScale(ffmpegProfile, version)) if (NeedToScale(ffmpegProfile, videoVersion))
{ {
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, version); IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion);
if (!scaledSize.IsSameSizeAs(version)) if (!scaledSize.IsSameSizeAs(videoVersion))
{ {
int fixedHeight = scaledSize.Height + scaledSize.Height % 2; int fixedHeight = scaledSize.Height + scaledSize.Height % 2;
int fixedWidth = scaledSize.Width + scaledSize.Width % 2; int fixedWidth = scaledSize.Width + scaledSize.Width % 2;
@@ -87,7 +87,7 @@ namespace ErsatzTV.Core.FFmpeg
} }
} }
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(version); IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(videoVersion);
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution)) if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
{ {
result.PadToDesiredResolution = true; result.PadToDesiredResolution = true;
@@ -98,32 +98,36 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoTrackTimeScale = 90000; result.VideoTrackTimeScale = 90000;
} }
if (result.ScaledSize.IsSome || result.PadToDesiredResolution || foreach (MediaStream stream in videoStream.Where(s => s.AttachedPic == false))
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
{ {
result.VideoCodec = ffmpegProfile.VideoCodec; if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
result.VideoBitrate = ffmpegProfile.VideoBitrate; NeedToNormalizeVideoCodec(ffmpegProfile, stream))
result.VideoBufferSize = ffmpegProfile.VideoBufferSize; {
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
result.VideoDecoder = result.VideoDecoder =
(result.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat) switch (result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
{ {
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le") => (HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
"h264", ) =>
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc", "h264",
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid", (HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid", (HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid", (HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid", (HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv", (HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv", (HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv", (HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
_ => null (HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
}; _ => null
} };
else }
{ else
result.VideoCodec = "copy"; {
result.VideoCodec = "copy";
}
} }
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio) if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
@@ -150,7 +154,7 @@ namespace ErsatzTV.Core.FFmpeg
result.AudioCodec = "copy"; result.AudioCodec = "copy";
} }
if (version.VideoScanKind == VideoScanKind.Interlaced) if (videoVersion.VideoScanKind == VideoScanKind.Interlaced)
{ {
result.Deinterlace = true; result.Deinterlace = true;
} }
+146 -55
View File
@@ -42,6 +42,8 @@ namespace ErsatzTV.Core.FFmpeg
private string _vaapiDevice; private string _vaapiDevice;
private HardwareAccelerationKind _hwAccel; private HardwareAccelerationKind _hwAccel;
private string _outputPixelFormat; private string _outputPixelFormat;
private bool _noAutoScale;
private Option<int> _outputFramerate;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger) public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
{ {
@@ -71,7 +73,7 @@ namespace ErsatzTV.Core.FFmpeg
return this; return this;
} }
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, string pixelFormat, string encoder) public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, Option<string> pixelFormat, string encoder)
{ {
_hwAccel = hwAccel; _hwAccel = hwAccel;
@@ -84,7 +86,7 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any"); _arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
break; break;
case HardwareAccelerationKind.Nvenc: case HardwareAccelerationKind.Nvenc:
string outputFormat = (encoder, pixelFormat) switch string outputFormat = (encoder, pixelFormat.IfNone("")) switch
{ {
("hevc_nvenc", "yuv420p10le") => "p010le", ("hevc_nvenc", "yuv420p10le") => "p010le",
("h264_nvenc", "yuv420p10le") => "p010le", ("h264_nvenc", "yuv420p10le") => "p010le",
@@ -147,6 +149,11 @@ namespace ErsatzTV.Core.FFmpeg
{ {
_arguments.Add("-stream_loop"); _arguments.Add("-stream_loop");
_arguments.Add("-1"); _arguments.Add("-1");
if (_hwAccel is HardwareAccelerationKind.Qsv or HardwareAccelerationKind.Vaapi)
{
_noAutoScale = true;
}
} }
return this; return this;
@@ -183,35 +190,73 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithInput(string input) public FFmpegProcessBuilder WithInput(string input)
{ {
_arguments.Add("-i"); _arguments.Add("-i");
_arguments.Add($"{input}"); _arguments.Add(input);
return this;
}
public FFmpegProcessBuilder WithMap(string map)
{
_arguments.Add("-map");
_arguments.Add(map);
return this; return this;
} }
public FFmpegProcessBuilder WithWatermark( public FFmpegProcessBuilder WithWatermark(
Option<ChannelWatermark> watermark, Option<WatermarkOptions> watermarkOptions,
Option<string> maybePath, IDisplaySize resolution)
IDisplaySize resolution,
bool isAnimated)
{ {
foreach (string path in maybePath) foreach (WatermarkOptions options in watermarkOptions)
{ {
if (isAnimated) foreach (string path in options.ImagePath)
{ {
_arguments.Add("-ignore_loop"); if (options.IsAnimated)
_arguments.Add("0"); {
_arguments.Add("-ignore_loop");
_arguments.Add("0");
}
_arguments.Add("-i");
_arguments.Add(path);
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(
options.Watermark,
resolution,
options.ImageStreamIndex);
} }
_arguments.Add("-i");
_arguments.Add(path);
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution);
} }
return this; return this;
} }
public FFmpegProcessBuilder WithInputCodec(string input, string decoder, string codec, string pixelFormat) public FFmpegProcessBuilder WithSubtitleFile(Option<string> subtitleFile)
{ {
_complexFilterBuilder = _complexFilterBuilder.WithSubtitleFile(subtitleFile);
return this;
}
public FFmpegProcessBuilder WithInputCodec(
Option<TimeSpan> maybeStart,
bool loop,
string videoPath,
string audioPath,
string decoder,
Option<string> codec,
Option<string> pixelFormat)
{
if (audioPath == videoPath)
{
WithSeek(maybeStart);
WithInfiniteLoop(loop);
}
else
{
_noAutoScale = true;
_outputFramerate = 30;
_arguments.Add("-loop");
_arguments.Add("1");
}
if (!string.IsNullOrWhiteSpace(decoder)) if (!string.IsNullOrWhiteSpace(decoder))
{ {
_arguments.Add("-c:v"); _arguments.Add("-c:v");
@@ -223,25 +268,36 @@ namespace ErsatzTV.Core.FFmpeg
.WithInputPixelFormat(pixelFormat); .WithInputPixelFormat(pixelFormat);
_arguments.Add("-i"); _arguments.Add("-i");
_arguments.Add($"{input}"); _arguments.Add(videoPath);
if (audioPath != videoPath)
{
WithSeek(maybeStart);
_arguments.Add("-i");
_arguments.Add(audioPath);
}
return this; return this;
} }
public FFmpegProcessBuilder WithFiltergraph(string graph) public FFmpegProcessBuilder WithSongInput(
string videoPath,
Option<string> codec,
Option<string> pixelFormat,
bool boxBlur)
{ {
_arguments.Add("-vf"); _noAutoScale = true;
_arguments.Add($"{graph}"); _outputFramerate = 30;
return this;
} _complexFilterBuilder = _complexFilterBuilder
.WithInputCodec(codec)
.WithInputPixelFormat(pixelFormat)
.WithBoxBlur(boxBlur);
_arguments.Add("-i");
_arguments.Add(videoPath);
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; return this;
} }
@@ -298,22 +354,6 @@ namespace ErsatzTV.Core.FFmpeg
return this; 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) public FFmpegProcessBuilder WithDuration(TimeSpan duration)
{ {
_arguments.Add("-t"); _arguments.Add("-t");
@@ -327,7 +367,7 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add($"{format}"); _arguments.Add($"{format}");
return this; return this;
} }
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion) public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
{ {
const int SEGMENT_SECONDS = 4; const int SEGMENT_SECONDS = 4;
@@ -430,6 +470,18 @@ namespace ErsatzTV.Core.FFmpeg
}); });
_arguments.AddRange(arguments); _arguments.AddRange(arguments);
if (_noAutoScale)
{
_arguments.Add("-noautoscale");
}
foreach (int framerate in _outputFramerate)
{
_arguments.Add("-r");
_arguments.Add(framerate.ToString());
}
return this; return this;
} }
@@ -473,10 +525,23 @@ namespace ErsatzTV.Core.FFmpeg
_complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace); _complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace);
return this; return this;
} }
public FFmpegProcessBuilder WithOutputFormat(string format, string output)
{
_arguments.Add("-f");
_arguments.Add(format);
_arguments.Add("-y");
_arguments.Add(output);
return this;
}
public FFmpegProcessBuilder WithFilterComplex( public FFmpegProcessBuilder WithFilterComplex(
MediaStream videoStream, MediaStream videoStream,
Option<MediaStream> maybeAudioStream, Option<MediaStream> maybeAudioStream,
string videoPath,
Option<string> audioPath,
string videoCodec) string videoCodec)
{ {
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec); _complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
@@ -484,10 +549,33 @@ namespace ErsatzTV.Core.FFmpeg
int videoStreamIndex = videoStream.Index; int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index); Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
var videoLabel = $"0:{videoStreamIndex}"; var videoIndex = 0;
var audioLabel = $"0:{maybeIndex.Match(i => i.ToString(), () => "a")}"; var audioIndex = 0;
if (audioPath.IsNone)
{
// no audio index, so use same as video
audioIndex = 0;
}
else if (audioPath.IfNone("NotARealPath") != videoPath)
{
audioIndex = 1;
if (_hwAccel == HardwareAccelerationKind.None)
{
_outputPixelFormat = "yuv420p";
}
}
var videoLabel = $"{videoIndex}:{videoStreamIndex}";
var audioLabel = $"{audioIndex}:{maybeIndex.Match(i => i.ToString(), () => "a")}";
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(
audioPath.IsNone,
videoIndex,
videoStreamIndex,
audioIndex,
maybeIndex,
audioPath.IsSome && videoPath != audioPath.IfNone("NotARealPath"));
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, maybeIndex);
maybeFilter.IfSome( maybeFilter.IfSome(
filter => filter =>
{ {
@@ -505,8 +593,11 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add("-map"); _arguments.Add("-map");
_arguments.Add(videoLabel); _arguments.Add(videoLabel);
_arguments.Add("-map"); foreach (string _ in audioPath)
_arguments.Add(audioLabel); {
_arguments.Add("-map");
_arguments.Add(audioLabel);
}
return this; return this;
} }
+238 -34
View File
@@ -12,10 +12,11 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg namespace ErsatzTV.Core.FFmpeg
{ {
public class FFmpegProcessService public class FFmpegProcessService : IFFmpegProcessService
{ {
private readonly IFFmpegStreamSelector _ffmpegStreamSelector; private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache; private readonly IImageCache _imageCache;
private readonly ITempFilePool _tempFilePool;
private readonly ILogger<FFmpegProcessService> _logger; private readonly ILogger<FFmpegProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator; private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
@@ -23,11 +24,13 @@ namespace ErsatzTV.Core.FFmpeg
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService, FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
IFFmpegStreamSelector ffmpegStreamSelector, IFFmpegStreamSelector ffmpegStreamSelector,
IImageCache imageCache, IImageCache imageCache,
ITempFilePool tempFilePool,
ILogger<FFmpegProcessService> logger) ILogger<FFmpegProcessService> logger)
{ {
_playbackSettingsCalculator = ffmpegPlaybackSettingsService; _playbackSettingsCalculator = ffmpegPlaybackSettingsService;
_ffmpegStreamSelector = ffmpegStreamSelector; _ffmpegStreamSelector = ffmpegStreamSelector;
_imageCache = imageCache; _imageCache = imageCache;
_tempFilePool = tempFilePool;
_logger = logger; _logger = logger;
} }
@@ -35,8 +38,10 @@ namespace ErsatzTV.Core.FFmpeg
string ffmpegPath, string ffmpegPath,
bool saveReports, bool saveReports,
Channel channel, Channel channel,
MediaVersion version, MediaVersion videoVersion,
string path, MediaVersion audioVersion,
string videoPath,
string audioPath,
DateTimeOffset start, DateTimeOffset start,
DateTimeOffset finish, DateTimeOffset finish,
DateTimeOffset now, DateTimeOffset now,
@@ -48,13 +53,13 @@ namespace ErsatzTV.Core.FFmpeg
TimeSpan inPoint, TimeSpan inPoint,
TimeSpan outPoint) TimeSpan outPoint)
{ {
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version); MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version); Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings( FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode, channel.StreamingMode,
channel.FFmpegProfile, channel.FFmpegProfile,
version, videoVersion,
videoStream, videoStream,
maybeAudioStream, maybeAudioStream,
start, start,
@@ -62,26 +67,30 @@ namespace ErsatzTV.Core.FFmpeg
inPoint, inPoint,
outPoint); outPoint);
(Option<ChannelWatermark> maybeWatermark, Option<string> maybeWatermarkPath) = Option<WatermarkOptions> watermarkOptions =
GetWatermarkOptions(channel, globalWatermark); await GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
bool isAnimated = await maybeWatermarkPath.Match(
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false));
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger) FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount) .WithThreads(playbackSettings.ThreadCount)
.WithVaapiDriver(vaapiDriver, vaapiDevice) .WithVaapiDriver(vaapiDriver, vaapiDevice)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration, videoStream.PixelFormat, playbackSettings.VideoCodec) .WithHardwareAcceleration(
playbackSettings.HardwareAcceleration,
videoStream.PixelFormat,
playbackSettings.VideoCodec)
.WithQuiet() .WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags) .WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput) .WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek) .WithInputCodec(
.WithInfiniteLoop(fillerKind == FillerKind.Fallback) playbackSettings.StreamSeek,
.WithInputCodec(path, playbackSettings.VideoDecoder, videoStream.Codec, videoStream.PixelFormat) fillerKind == FillerKind.Fallback,
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated) videoPath,
audioPath,
playbackSettings.VideoDecoder,
videoStream.Codec,
videoStream.PixelFormat)
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale) .WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(playbackSettings.AudioDuration) .WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None)
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness); .WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
playbackSettings.ScaledSize.Match( playbackSettings.ScaledSize.Match(
@@ -96,7 +105,12 @@ namespace ErsatzTV.Core.FFmpeg
} }
builder = builder builder = builder
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec); .WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
}, },
() => () =>
{ {
@@ -105,18 +119,33 @@ namespace ErsatzTV.Core.FFmpeg
builder = builder builder = builder
.WithDeinterlace(playbackSettings.Deinterlace) .WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution) .WithBlackBars(channel.FFmpegProfile.Resolution)
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec); .WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
} }
else if (playbackSettings.Deinterlace) else if (playbackSettings.Deinterlace)
{ {
builder = builder.WithDeinterlace(playbackSettings.Deinterlace) builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration) .WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec); .WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
} }
else else
{ {
builder = builder builder = builder
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec); .WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
} }
}); });
@@ -128,7 +157,7 @@ namespace ErsatzTV.Core.FFmpeg
{ {
// HLS needs to segment and generate playlist // HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter: case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, version) return builder.WithHls(channel.Number, videoVersion)
.WithRealtimeOutput(hlsRealtime) .WithRealtimeOutput(hlsRealtime)
.Build(); .Build();
default: default:
@@ -138,7 +167,7 @@ namespace ErsatzTV.Core.FFmpeg
} }
} }
public Process ForError( public async Task<Process> ForError(
string ffmpegPath, string ffmpegPath,
Channel channel, Channel channel,
Option<TimeSpan> duration, Option<TimeSpan> duration,
@@ -150,6 +179,22 @@ namespace ErsatzTV.Core.FFmpeg
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution; 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) FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1) .WithThreads(1)
.WithQuiet() .WithQuiet()
@@ -158,12 +203,18 @@ namespace ErsatzTV.Core.FFmpeg
.WithLoopedImage(Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png")) .WithLoopedImage(Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"))
.WithLibavfilter() .WithLibavfilter()
.WithInput("anullsrc") .WithInput("anullsrc")
.WithErrorText(desiredResolution, errorMessage) .WithSubtitleFile(subtitleFile)
.WithFilterComplex(
videoStream,
audioStream,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"),
"fake-audio-path",
playbackSettings.VideoCodec)
.WithPixfmt("yuv420p") .WithPixfmt("yuv420p")
.WithPlaybackArgs(playbackSettings) .WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, None); .WithMetadata(channel, None);
duration.IfSome(d => builder = builder.WithDuration(d)); await duration.IfSomeAsync(d => builder = builder.WithDuration(d));
switch (channel.StreamingMode) switch (channel.StreamingMode)
{ {
@@ -196,14 +247,149 @@ namespace ErsatzTV.Core.FFmpeg
.Build(); .Build();
} }
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithInput(inputFile)
.WithOutputFormat("apng", outputFile)
.Build();
}
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> subtitleFile,
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath,
bool boxBlur,
Option<string> watermarkPath,
ChannelWatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent)
{
try
{
string outputFile = _tempFilePool.GetNextTempFile(TempFileCategory.SongBackground);
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, watermarkOverride, watermarkPath);
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
FFmpegPlaybackSettings scalePlaybackSettings = _playbackSettingsCalculator.CalculateSettings(
StreamingMode.TransportStream,
channel.FFmpegProfile,
videoVersion,
videoStream,
None,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch,
TimeSpan.Zero,
TimeSpan.Zero);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur)
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
.WithSubtitleFile(subtitleFile);
foreach (IDisplaySize scaledSize in scalePlaybackSettings.ScaledSize)
{
builder = builder.WithScaling(scaledSize);
if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize))
{
builder = builder.WithBlackBars(channel.FFmpegProfile.Resolution);
}
}
using Process process = builder
.WithFilterComplex(
videoStream,
None,
videoPath,
None,
playbackSettings.VideoCodec)
.WithOutputFormat("apng", outputFile)
.Build();
_logger.LogInformation(
"ffmpeg song arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
await process.WaitForExitAsync();
return outputFile;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error generating song image");
return Left(BaseError.New(ex.Message));
}
}
private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) => private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) =>
displaySize.Width != target.Width || displaySize.Height != target.Height; displaySize.Width != target.Width || displaySize.Height != target.Height;
private WatermarkOptions GetWatermarkOptions(Channel channel, Option<ChannelWatermark> globalWatermark) private async Task<WatermarkOptions> GetWatermarkOptions(
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
Option<ChannelWatermark> watermarkOverride,
Option<string> watermarkPath)
{ {
if (videoVersion is BackgroundImageMediaVersion)
{
return new WatermarkOptions(None, None, None, false);
}
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode && if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo) channel.FFmpegProfile.NormalizeVideo)
{ {
if (videoVersion is CoverArtMediaVersion)
{
return new WatermarkOptions(
watermarkOverride,
await watermarkPath.IfNoneAsync(videoVersion.MediaFiles.Head().Path),
0,
false);
}
// check for channel watermark // check for channel watermark
if (channel.Watermark != null) if (channel.Watermark != null)
{ {
@@ -214,13 +400,23 @@ namespace ErsatzTV.Core.FFmpeg
channel.Watermark.Image, channel.Watermark.Image,
ArtworkKind.Watermark, ArtworkKind.Watermark,
Option<int>.None); Option<int>.None);
return new WatermarkOptions(channel.Watermark, customPath); return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(channel.Watermark),
customPath,
None,
await _imageCache.IsAnimated(customPath));
case ChannelWatermarkImageSource.ChannelLogo: case ChannelWatermarkImageSource.ChannelLogo:
Option<string> maybeChannelPath = channel.Artwork Option<string> maybeChannelPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo) .Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone() .HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None)); .Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(channel.Watermark, maybeChannelPath); return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(channel.Watermark),
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false)));
default: default:
throw new NotSupportedException("Unsupported watermark image source"); throw new NotSupportedException("Unsupported watermark image source");
} }
@@ -236,22 +432,30 @@ namespace ErsatzTV.Core.FFmpeg
watermark.Image, watermark.Image,
ArtworkKind.Watermark, ArtworkKind.Watermark,
Option<int>.None); Option<int>.None);
return new WatermarkOptions(watermark, customPath); return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
customPath,
None,
await _imageCache.IsAnimated(customPath));
case ChannelWatermarkImageSource.ChannelLogo: case ChannelWatermarkImageSource.ChannelLogo:
Option<string> maybeChannelPath = channel.Artwork Option<string> maybeChannelPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo) .Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone() .HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None)); .Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(watermark, maybeChannelPath); return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false)));
default: default:
throw new NotSupportedException("Unsupported watermark image source"); throw new NotSupportedException("Unsupported watermark image source");
} }
} }
} }
return new WatermarkOptions(None, None); return new WatermarkOptions(None, None, None, false);
} }
private record WatermarkOptions(Option<ChannelWatermark> Watermark, Option<string> ImagePath);
} }
} }
+255
View File
@@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
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 backgroundPath = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
backgrounds[NextRandom(backgrounds.Length)]);
Option<string> watermarkPath = None;
var boxBlur = false;
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("&H444444")
.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)))
{
// 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 customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
watermarkPath = customPath;
// randomize selected blur hash
var hashes = new List<string>
{
artwork.BlurHash43,
artwork.BlurHash54,
artwork.BlurHash64
}.Filter(s => !string.IsNullOrWhiteSpace(s)).ToList();
if (hashes.Any())
{
string hash = hashes[NextRandom(hashes.Count)];
backgroundPath = await _imageCache.WriteBlurHash(
hash,
channel.FFmpegProfile.Resolution);
videoVersion.Height = channel.FFmpegProfile.Resolution.Height;
videoVersion.Width = channel.FFmpegProfile.Resolution.Width;
}
else
{
backgroundPath = customPath;
boxBlur = true;
}
}
}
string videoPath = backgroundPath;
videoVersion.MediaFiles = new List<MediaFile>
{
new() { Path = videoPath }
};
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
subtitleFile,
channel,
maybeGlobalWatermark,
videoVersion,
videoPath,
boxBlur,
watermarkPath,
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, Outline, Shadow, Alignment, Encoding");
sb.AppendLine($"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{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;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.FFmpeg
{
public enum TempFileCategory
{
Subtitle = 0,
SongBackground = 1,
CoverArt = 2,
CachedArtwork = 3
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.IO;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.FFmpeg
{
public class TempFilePool : ITempFilePool
{
private const int ItemLimit = 10;
private readonly Dictionary<TempFileCategory, int> _state = new();
private readonly object _lock = new();
public string GetNextTempFile(TempFileCategory category)
{
lock (_lock)
{
var index = 0;
if (_state.TryGetValue(category, out int current))
{
index = (current + 1) % ItemLimit;
}
_state[category] = index;
return GetFileName(category, index);
}
}
private static string GetFileName(TempFileCategory category, int index)
{
return Path.Combine(FileSystemLayout.TempFilePoolFolder, $"{category}_{index}".ToLowerInvariant());
}
}
}
+11
View File
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.FFmpeg
{
public record WatermarkOptions(
Option<ChannelWatermark> Watermark,
Option<string> ImagePath,
Option<int> ImageStreamIndex,
bool IsAnimated);
}
+1
View File
@@ -31,6 +31,7 @@ namespace ErsatzTV.Core
public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports"); public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index"); public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index");
public static readonly string TempFilePoolFolder = Path.Combine(AppDataFolder, "temp-pool");
public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork"); public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork");
@@ -0,0 +1,59 @@
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.FFmpeg;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegProcessService
{
Task<Process> ForPlayoutItem(
string ffmpegPath,
bool saveReports,
Channel channel,
MediaVersion videoVersion,
MediaVersion audioVersion,
string videoPath,
string audioPath,
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
Option<ChannelWatermark> globalWatermark,
VaapiDriver vaapiDriver,
string vaapiDevice,
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
TimeSpan outPoint);
Task<Process> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime);
Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
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> subtitleFile,
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath,
bool boxBlur,
Option<string> watermarkPath,
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);
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface ITempFilePool
{
string GetNextTempFile(TempFileCategory category);
}
}
@@ -1,5 +1,7 @@
using System.Threading.Tasks; using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Images namespace ErsatzTV.Core.Interfaces.Images
@@ -7,9 +9,11 @@ namespace ErsatzTV.Core.Interfaces.Images
public interface IImageCache public interface IImageCache
{ {
Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height); 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); Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight); string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
Task<bool> IsAnimated(string fileName); Task<bool> IsAnimated(string fileName);
Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y);
Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize);
} }
} }
@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
MovieMetadata GetFallbackMetadata(Movie movie); MovieMetadata GetFallbackMetadata(Movie movie);
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo); Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo); Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo);
Option<SongMetadata> GetFallbackMetadata(Song song);
string GetSortTitle(string title); string GetSortTitle(string title);
} }
} }
@@ -12,11 +12,13 @@ namespace ErsatzTV.Core.Interfaces.Metadata
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName); Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName); Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName); Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
Task<bool> RefreshTagMetadata(Song song, string ffprobePath);
Task<bool> RefreshFallbackMetadata(Movie movie); Task<bool> RefreshFallbackMetadata(Movie movie);
Task<bool> RefreshFallbackMetadata(Episode episode); Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder); Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo); Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo); Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo);
Task<bool> RefreshFallbackMetadata(Song song);
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder); Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
} }
} }
@@ -1,4 +1,5 @@
using System.Threading.Tasks; using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt; using LanguageExt;
@@ -8,5 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{ {
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem); Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem, string mediaItemPath); Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem, string mediaItemPath);
Task<Either<BaseError, Dictionary<string, string>>> GetFormatTags(string ffprobePath, MediaItem mediaItem);
} }
} }
@@ -0,0 +1,16 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ISongFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
string ffmpegPath,
decimal progressMin,
decimal progressMax);
}
}
@@ -20,6 +20,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Unit> UpdateArtworkPath(Artwork artwork); Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork); Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind); Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
Task<bool> CloneArtwork(
Domain.Metadata metadata,
Option<Artwork> maybeArtwork,
ArtworkKind artworkKind,
string sourcePath,
DateTime lastWriteTime);
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated); Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated); Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated); Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ISongRepository
{
Task<Either<BaseError, MediaItemScanResult<Song>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<IEnumerable<string>> FindSongPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddGenre(SongMetadata metadata, Genre genre);
Task<bool> AddTag(SongMetadata metadata, Tag tag);
Task<List<SongMetadata>> GetSongsForCards(List<int> ids);
}
}
+30
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)) if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
{ {
@@ -342,6 +365,10 @@ namespace ErsatzTV.Core.Iptv
.IfNone("[unknown show]"), .IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty) MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"), .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.Artist ?? string.Empty)
.IfNone("[unknown artist]"),
_ => "[unknown]" _ => "[unknown]"
}; };
} }
@@ -361,6 +388,9 @@ namespace ErsatzTV.Core.Iptv
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match( MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty, mvm => mvm.Title ?? string.Empty,
() => string.Empty), () => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty _ => string.Empty
}; };
} }
@@ -104,6 +104,20 @@ namespace ErsatzTV.Core.Metadata
return GetOtherVideoMetadata(path, metadata); return GetOtherVideoMetadata(path, metadata);
} }
public Option<SongMetadata> GetFallbackMetadata(Song song)
{
string path = song.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileNameWithoutExtension(path);
var metadata = new SongMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
Song = song
};
return GetSongMetadata(path, metadata);
}
public string GetSortTitle(string title) public string GetSortTitle(string title)
{ {
if (string.IsNullOrWhiteSpace(title)) if (string.IsNullOrWhiteSpace(title))
@@ -266,6 +280,43 @@ namespace ErsatzTV.Core.Metadata
return None; return None;
} }
} }
private Option<SongMetadata> GetSongMetadata(string path, SongMetadata metadata)
{
try
{
string folder = Path.GetDirectoryName(path);
if (folder == null)
{
return None;
}
string libraryPath = metadata.Song.LibraryPath.Path;
string parent = Optional(Directory.GetParent(libraryPath)).Match(
di => di.FullName,
() => libraryPath);
string diff = Path.GetRelativePath(parent, folder);
var tags = diff.Split(Path.DirectorySeparatorChar)
.Map(t => new Tag { Name = t })
.ToList();
metadata.Artwork = new List<Artwork>();
metadata.Actors = new List<Actor>();
metadata.Genres = new List<Genre>();
metadata.Tags = tags;
metadata.Studios = new List<Studio>();
metadata.DateUpdated = DateTime.UtcNow;
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
return metadata;
}
catch (Exception)
{
return None;
}
}
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata) private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
{ {
+1 -1
View File
@@ -9,7 +9,7 @@ namespace ErsatzTV.Core.Metadata
{ {
public static class FolderEtag public static class FolderEtag
{ {
private static readonly MD5CryptoServiceProvider Crypto = new(); private static readonly MD5 Crypto = MD5.Create();
public static string Calculate(string folder, ILocalFileSystem localFileSystem) public static string Calculate(string folder, ILocalFileSystem localFileSystem)
{ {
+107 -10
View File
@@ -1,9 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -17,7 +21,12 @@ namespace ErsatzTV.Core.Metadata
public static readonly List<string> VideoFileExtensions = new() public static readonly List<string> VideoFileExtensions = new()
{ {
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts" ".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts", ".webm"
};
public static readonly List<string> AudioFileExtensions = new()
{
".aac", ".alac", ".flac", ".mp3", ".m4a", ".wav", ".wma"
}; };
public static readonly List<string> ImageFileExtensions = new() public static readonly List<string> ImageFileExtensions = new()
@@ -41,6 +50,8 @@ namespace ErsatzTV.Core.Metadata
.ToList(); .ToList();
private readonly IImageCache _imageCache; private readonly IImageCache _imageCache;
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly ITempFilePool _tempFilePool;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
@@ -52,12 +63,16 @@ namespace ErsatzTV.Core.Metadata
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
IImageCache imageCache, IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger logger) ILogger logger)
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider; _localStatisticsProvider = localStatisticsProvider;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_imageCache = imageCache; _imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
_tempFilePool = tempFilePool;
_logger = logger; _logger = logger;
} }
@@ -68,14 +83,7 @@ namespace ErsatzTV.Core.Metadata
{ {
try try
{ {
MediaVersion version = mediaItem.Item switch MediaVersion version = mediaItem.Item.GetHeadVersion();
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
string path = version.MediaFiles.Head().Path; string path = version.MediaFiles.Head().Path;
@@ -108,7 +116,12 @@ namespace ErsatzTV.Core.Metadata
} }
} }
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind) protected async Task<bool> RefreshArtwork(
string artworkFile,
Domain.Metadata metadata,
ArtworkKind artworkKind,
Option<string> ffmpegPath,
Option<int> attachedPicIndex)
{ {
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile); DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
@@ -125,6 +138,48 @@ namespace ErsatzTV.Core.Metadata
try try
{ {
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile); _logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
string sourcePath = artworkFile;
if (await _metadataRepository.CloneArtwork(
metadata,
maybeArtwork,
artworkKind,
sourcePath,
lastWriteTime))
{
return true;
}
// if ffmpeg path is passed, we need pre-processing
foreach (string path in ffmpegPath)
{
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();
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 = Either<BaseError, string> maybeCacheName =
await _imageCache.CopyArtworkToCache(artworkFile, artworkKind); await _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
@@ -135,7 +190,28 @@ namespace ErsatzTV.Core.Metadata
async artwork => async artwork =>
{ {
artwork.Path = cacheName; artwork.Path = cacheName;
artwork.SourcePath = sourcePath;
artwork.DateUpdated = lastWriteTime; artwork.DateUpdated = lastWriteTime;
if (metadata is SongMetadata)
{
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
4,
3);
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
5,
4);
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
6,
4);
}
await _metadataRepository.UpdateArtworkPath(artwork); await _metadataRepository.UpdateArtworkPath(artwork);
}, },
async () => async () =>
@@ -143,10 +219,31 @@ namespace ErsatzTV.Core.Metadata
var artwork = new Artwork var artwork = new Artwork
{ {
Path = cacheName, Path = cacheName,
SourcePath = sourcePath,
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
DateUpdated = lastWriteTime, DateUpdated = lastWriteTime,
ArtworkKind = artworkKind ArtworkKind = artworkKind
}; };
if (metadata is SongMetadata)
{
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
4,
3);
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
5,
4);
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
6,
4);
}
metadata.Artwork.Add(artwork); metadata.Artwork.Add(artwork);
await _metadataRepository.AddArtwork(metadata, artwork); await _metadataRepository.AddArtwork(metadata, artwork);
}); });
+163 -5
View File
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Xml.Serialization; using System.Xml.Serialization;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Metadata.Nfo; using ErsatzTV.Core.Interfaces.Metadata.Nfo;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -23,6 +24,7 @@ namespace ErsatzTV.Core.Metadata
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo)); private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IArtistRepository _artistRepository; private readonly IArtistRepository _artistRepository;
private readonly IEpisodeNfoReader _episodeNfoReader; private readonly IEpisodeNfoReader _episodeNfoReader;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger; private readonly ILogger<LocalMetadataProvider> _logger;
@@ -31,6 +33,7 @@ namespace ErsatzTV.Core.Metadata
private readonly IMovieRepository _movieRepository; private readonly IMovieRepository _movieRepository;
private readonly IMusicVideoRepository _musicVideoRepository; private readonly IMusicVideoRepository _musicVideoRepository;
private readonly IOtherVideoRepository _otherVideoRepository; private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ISongRepository _songRepository;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider( public LocalMetadataProvider(
@@ -40,9 +43,11 @@ namespace ErsatzTV.Core.Metadata
IArtistRepository artistRepository, IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository, IMusicVideoRepository musicVideoRepository,
IOtherVideoRepository otherVideoRepository, IOtherVideoRepository otherVideoRepository,
ISongRepository songRepository,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IEpisodeNfoReader episodeNfoReader, IEpisodeNfoReader episodeNfoReader,
ILocalStatisticsProvider localStatisticsProvider,
ILogger<LocalMetadataProvider> logger) ILogger<LocalMetadataProvider> logger)
{ {
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
@@ -51,9 +56,11 @@ namespace ErsatzTV.Core.Metadata
_artistRepository = artistRepository; _artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository; _musicVideoRepository = musicVideoRepository;
_otherVideoRepository = otherVideoRepository; _otherVideoRepository = otherVideoRepository;
_songRepository = songRepository;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_episodeNfoReader = episodeNfoReader; _episodeNfoReader = episodeNfoReader;
_localStatisticsProvider = localStatisticsProvider;
_logger = logger; _logger = logger;
} }
@@ -130,6 +137,12 @@ namespace ErsatzTV.Core.Metadata
metadata => ApplyMetadataUpdate(musicVideo, metadata), metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => RefreshFallbackMetadata(musicVideo))); () => RefreshFallbackMetadata(musicVideo)));
public Task<bool> RefreshTagMetadata(Song song, string ffprobePath) =>
LoadSongMetadata(song, ffprobePath).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(song, metadata),
() => RefreshFallbackMetadata(song)));
public Task<bool> RefreshFallbackMetadata(Movie movie) => public Task<bool> RefreshFallbackMetadata(Movie movie) =>
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie)); ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
@@ -144,6 +157,11 @@ namespace ErsatzTV.Core.Metadata
metadata => ApplyMetadataUpdate(otherVideo, metadata), metadata => ApplyMetadataUpdate(otherVideo, metadata),
() => Task.FromResult(false)); () => Task.FromResult(false));
public Task<bool> RefreshFallbackMetadata(Song song) =>
_fallbackMetadataProvider.GetFallbackMetadata(song).Match(
metadata => ApplyMetadataUpdate(song, metadata),
() => Task.FromResult(false));
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) => public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match( _fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata), metadata => ApplyMetadataUpdate(musicVideo, metadata),
@@ -182,6 +200,91 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private async Task<Option<SongMetadata>> LoadSongMetadata(Song song, string ffprobePath)
{
string path = song.GetHeadVersion().MediaFiles.Head().Path;
try
{
Either<BaseError, Dictionary<string, string>> maybeTags =
await _localStatisticsProvider.GetFormatTags(ffprobePath, song);
return maybeTags.Match(
tags =>
{
Option<SongMetadata> maybeFallbackMetadata =
_fallbackMetadataProvider.GetFallbackMetadata(song);
var result = new SongMetadata
{
MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path),
Artwork = new List<Artwork>(),
Actors = new List<Actor>(),
Genres = new List<Genre>(),
Studios = new List<Studio>(),
Tags = new List<Tag>()
};
if (tags.TryGetValue(MetadataFormatTag.Album, out string album))
{
result.Album = album;
}
if (tags.TryGetValue(MetadataFormatTag.Artist, out string artist))
{
result.Artist = artist;
}
if (tags.TryGetValue(MetadataFormatTag.Date, out string date))
{
result.Date = date;
}
if (tags.TryGetValue(MetadataFormatTag.Genre, out string genre))
{
result.Genres.AddRange(SplitGenres(genre).Map(n => new Genre { Name = n }));
}
if (tags.TryGetValue(MetadataFormatTag.Title, out string title))
{
result.Title = title;
}
if (tags.TryGetValue(MetadataFormatTag.Track, out string track))
{
result.Track = track;
}
foreach (SongMetadata fallbackMetadata in maybeFallbackMetadata)
{
if (string.IsNullOrWhiteSpace(result.Title))
{
result.Title = fallbackMetadata.Title;
}
result.OriginalTitle = fallbackMetadata.OriginalTitle;
// preserve folder tagging - maybe someone uses this
foreach (Tag tag in fallbackMetadata.Tags)
{
result.Tags.Add(tag);
}
}
return result;
},
_ => Option<SongMetadata>.None);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read embedded song metadata from {Path}", path);
return None;
}
}
private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata) private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata)
{ {
var updated = false; var updated = false;
@@ -661,6 +764,49 @@ namespace ErsatzTV.Core.Metadata
return await _metadataRepository.Add(metadata); return await _metadataRepository.Add(metadata);
}); });
private Task<bool> ApplyMetadataUpdate(Song song, SongMetadata metadata) =>
Optional(song.SongMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Title = metadata.Title;
existing.Artist = metadata.Artist;
existing.Album = metadata.Album;
existing.Date = metadata.Date;
existing.Track = metadata.Track;
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
existing.OriginalTitle = metadata.OriginalTitle;
bool updated = await UpdateMetadataCollections(
existing,
metadata,
_songRepository.AddGenre,
_songRepository.AddTag,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false));
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.SongId = song.Id;
song.SongMetadata = new List<SongMetadata> { metadata };
return await _metadataRepository.Add(metadata);
});
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName) private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
{ {
@@ -823,9 +969,9 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private static int? GetYear(int year, string premiered) private static int? GetYear(int? year, string premiered)
{ {
if (year > 1000) if (year is > 1000)
{ {
return year; return year;
} }
@@ -843,9 +989,9 @@ namespace ErsatzTV.Core.Metadata
return null; return null;
} }
private static DateTime? GetAired(int year, string aired) private static DateTime? GetAired(int? year, string aired)
{ {
DateTime? fallback = year > 1000 ? new DateTime(year, 1, 1) : null; DateTime? fallback = year is > 1000 ? new DateTime(year.Value, 1, 1) : null;
if (string.IsNullOrWhiteSpace(aired)) if (string.IsNullOrWhiteSpace(aired))
{ {
@@ -931,7 +1077,7 @@ namespace ErsatzTV.Core.Metadata
} }
} }
if (existing is not MusicVideoMetadata) if (existing is not MusicVideoMetadata and not SongMetadata)
{ {
foreach (Actor actor in existing.Actors foreach (Actor actor in existing.Actors
.Filter(a => incoming.Actors.All(a2 => a2.Name != a.Name)) .Filter(a => incoming.Actors.All(a2 => a2.Name != a.Name))
@@ -990,5 +1136,17 @@ namespace ErsatzTV.Core.Metadata
return result; 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());
}
} }
} }
@@ -3,8 +3,10 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
@@ -34,15 +36,7 @@ namespace ErsatzTV.Core.Metadata
{ {
try try
{ {
string filePath = mediaItem switch string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
{
Movie m => m.MediaVersions.Head().MediaFiles.Head().Path,
Episode e => e.MediaVersions.Head().MediaFiles.Head().Path,
MusicVideo mv => mv.MediaVersions.Head().MediaFiles.Head().Path,
OtherVideo ov => ov.MediaVersions.Head().MediaFiles.Head().Path,
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
return await RefreshStatistics(ffprobePath, mediaItem, filePath); return await RefreshStatistics(ffprobePath, mediaItem, filePath);
} }
catch (Exception ex) catch (Exception ex)
@@ -76,16 +70,63 @@ namespace ErsatzTV.Core.Metadata
} }
} }
public async Task<Either<BaseError, Dictionary<string, string>>> GetFormatTags(
string ffprobePath,
MediaItem mediaItem)
{
try
{
string mediaItemPath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, mediaItemPath);
return maybeProbe.Match(
ffprobe =>
{
var result = new Dictionary<string, string>();
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.album))
{
result.Add(MetadataFormatTag.Album, ffprobe.format.tags.album);
}
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.artist))
{
result.Add(MetadataFormatTag.Artist, ffprobe.format.tags.artist);
}
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.date))
{
result.Add(MetadataFormatTag.Date, ffprobe.format.tags.date);
}
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.genre))
{
result.Add(MetadataFormatTag.Genre, ffprobe.format.tags.genre);
}
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.title))
{
result.Add(MetadataFormatTag.Title, ffprobe.format.tags.title);
}
if (!string.IsNullOrWhiteSpace(ffprobe?.format?.tags?.track))
{
result.Add(MetadataFormatTag.Track, ffprobe.format.tags.track);
}
return Right<BaseError, Dictionary<string, string>>(result);
},
Left<BaseError, Dictionary<string, string>>);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get format tags for media item {Id}", mediaItem.Id);
return BaseError.New(ex.Message);
}
}
private async Task<bool> ApplyVersionUpdate(MediaItem mediaItem, MediaVersion version, string filePath) private async Task<bool> ApplyVersionUpdate(MediaItem mediaItem, MediaVersion version, string filePath)
{ {
MediaVersion mediaItemVersion = mediaItem switch MediaVersion mediaItemVersion = mediaItem.GetHeadVersion();
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
bool durationChange = mediaItemVersion.Duration != version.Duration; bool durationChange = mediaItemVersion.Duration != version.Duration;
@@ -101,7 +142,9 @@ namespace ErsatzTV.Core.Metadata
FileName = ffprobePath, FileName = ffprobePath,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false UseShellExecute = false,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
}; };
startInfo.ArgumentList.Add("-v"); startInfo.ArgumentList.Add("-v");
@@ -221,6 +264,7 @@ namespace ErsatzTV.Core.Metadata
{ {
stream.Default = videoStream.disposition.@default == 1; stream.Default = videoStream.disposition.@default == 1;
stream.Forced = videoStream.disposition.forced == 1; stream.Forced = videoStream.disposition.forced == 1;
stream.AttachedPic = videoStream.disposition.attached_pic == 1;
} }
version.Streams.Add(stream); version.Streams.Add(stream);
@@ -290,7 +334,7 @@ namespace ErsatzTV.Core.Metadata
last.EndTime = version.Duration; last.EndTime = version.Duration;
} }
} }
return version; return version;
}, },
_ => new MediaVersion _ => new MediaVersion
@@ -312,12 +356,20 @@ namespace ErsatzTV.Core.Metadata
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
public record FFprobe(FFprobeFormat format, List<FFprobeStream> streams, List<FFprobeChapter> chapters); public record FFprobe(FFprobeFormat format, List<FFprobeStream> streams, List<FFprobeChapter> chapters);
public record FFprobeFormat(string duration); public record FFprobeFormat(string duration, FFprobeFormatTags tags);
public record FFprobeDisposition(int @default, int forced); public record FFprobeDisposition(int @default, int forced, int attached_pic);
public record FFprobeTags(string language, string title); public record FFprobeTags(string language, string title);
public record FFprobeFormatTags(
string title,
string artist,
string album,
string track,
string genre,
string date);
public record FFprobeStream( public record FFprobeStream(
int index, int index,
string codec_name, string codec_name,
@@ -6,7 +6,7 @@ namespace ErsatzTV.Core.Metadata
{ {
public MediaItemScanResult(T item) => Item = item; public MediaItemScanResult(T item) => Item = item;
public T Item { get; } public T Item { get; set; }
public bool IsAdded { get; set; } public bool IsAdded { get; set; }
public bool IsUpdated { get; set; } public bool IsUpdated { get; set; }
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Metadata
{
public static class MetadataFormatTag
{
public static readonly string Album = "album";
public static readonly string Artist = "artist";
public static readonly string Date = "date";
public static readonly string Genre = "genre";
public static readonly string Title = "title";
public static readonly string Track = "track";
}
}
+18 -4
View File
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -40,8 +41,17 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository, ISearchRepository searchRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IMediator mediator, IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<MovieFolderScanner> logger) ILogger<MovieFolderScanner> logger)
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
logger)
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_movieRepository = movieRepository; _movieRepository = movieRepository;
@@ -69,7 +79,9 @@ namespace ErsatzTV.Core.Metadata
var foldersCompleted = 0; var foldersCompleted = 0;
var folderQueue = new Queue<string>(); var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity)) foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{ {
folderQueue.Enqueue(folder); folderQueue.Enqueue(folder);
} }
@@ -95,7 +107,9 @@ namespace ErsatzTV.Core.Metadata
if (allFiles.Count == 0) if (allFiles.Count == 0)
{ {
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder).OrderBy(identity)) foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{ {
folderQueue.Enqueue(subdirectory); folderQueue.Enqueue(subdirectory);
} }
@@ -225,7 +239,7 @@ namespace ErsatzTV.Core.Metadata
async posterFile => async posterFile =>
{ {
MovieMetadata metadata = movie.MovieMetadata.Head(); MovieMetadata metadata = movie.MovieMetadata.Head();
await RefreshArtwork(posterFile, metadata, artworkKind); await RefreshArtwork(posterFile, metadata, artworkKind, None, None);
}); });
return result; return result;
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -41,11 +42,15 @@ namespace ErsatzTV.Core.Metadata
IMusicVideoRepository musicVideoRepository, IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IMediator mediator, IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<MusicVideoFolderScanner> logger) : base( ILogger<MusicVideoFolderScanner> logger) : base(
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
imageCache, imageCache,
ffmpegProcessService,
tempFilePool,
logger) logger)
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
@@ -217,7 +222,7 @@ namespace ErsatzTV.Core.Metadata
async artworkFile => async artworkFile =>
{ {
ArtistMetadata metadata = artist.ArtistMetadata.Head(); ArtistMetadata metadata = artist.ArtistMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind); await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
}); });
return result; return result;
@@ -380,7 +385,7 @@ namespace ErsatzTV.Core.Metadata
async thumbnailFile => async thumbnailFile =>
{ {
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head(); MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail); await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None);
}); });
return result; return result;
+8 -1
View File
@@ -9,8 +9,15 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("title")] [XmlElement("title")]
public string Title { get; set; } public string Title { get; set; }
[XmlIgnore]
public int? Year { get; set; }
[XmlElement("year")] [XmlElement("year")]
public int Year { get; set; } public string YearAsText
{
get => Year.HasValue ? Year.ToString() : null;
set => Year = !string.IsNullOrWhiteSpace(value) ? int.Parse(value) : default(int?);
}
[XmlElement("plot")] [XmlElement("plot")]
public string Plot { get; set; } public string Plot { get; set; }
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -39,11 +40,15 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository, ISearchRepository searchRepository,
IOtherVideoRepository otherVideoRepository, IOtherVideoRepository otherVideoRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<OtherVideoFolderScanner> logger) : base( ILogger<OtherVideoFolderScanner> logger) : base(
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
imageCache, imageCache,
ffmpegProcessService,
tempFilePool,
logger) logger)
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
+275
View File
@@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Core.Metadata
{
public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMediator _mediator;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ISongRepository _songRepository;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<SongFolderScanner> _logger;
public SongFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
IMetadataRepository metadataRepository,
IImageCache imageCache,
IMediator mediator,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ISongRepository songRepository,
ILibraryRepository libraryRepository,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<SongFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
logger)
{
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_mediator = mediator;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_songRepository = songRepository;
_libraryRepository = libraryRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
string ffmpegPath,
decimal progressMin,
decimal progressMax)
{
decimal progressSpread = progressMax - progressMin;
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{
return new MediaSourceInaccessible();
}
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
string songFolder = folderQueue.Dequeue();
foldersCompleted++;
var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList();
var allFiles = filesForEtag
.Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
.ToList();
foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
string etag = FolderEtag.Calculate(songFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == songFolder)
.HeadOrNone();
// skip folder if etag matches
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
{
continue;
}
_logger.LogDebug(
"UPDATE: Etag has changed for folder {Folder}",
songFolder);
foreach (string file in allFiles.OrderBy(identity))
{
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(video => UpdateThumbnail(video, ffmpegPath));
await maybeSong.Match(
async result =>
{
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
await _libraryRepository.SetEtag(libraryPath, knownFolder, songFolder, etag);
},
error =>
{
_logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
}
}
foreach (string path in await _songRepository.FindSongPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing song at {Path}", path);
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(songIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(songIds);
}
}
_searchIndex.Commit();
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateMetadata(
MediaItemScanResult<Song> result, string ffprobePath)
{
try
{
Song song = result.Item;
string path = song.GetHeadVersion().MediaFiles.Head().Path;
bool shouldUpdate = Optional(song.SongMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated != _localFileSystem.GetLastWriteTime(path),
true);
if (shouldUpdate)
{
song.SongMetadata ??= new List<SongMetadata>();
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path);
if (await _localMetadataProvider.RefreshTagMetadata(song, ffprobePath))
{
result.IsUpdated = true;
}
}
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail(
MediaItemScanResult<Song> result,
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, None);
},
() => ExtractEmbeddedArtwork(song, ffmpegPath));
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateThumbnail(Song song)
{
string path = song.MediaVersions.Head().MediaFiles.Head().Path;
Option<DirectoryInfo> parent = Optional(Directory.GetParent(path));
return parent.Map(
di =>
{
string coverPath = Path.Combine(di.FullName, "cover.jpg");
return ImageFileExtensions
.Map(ext => Path.ChangeExtension(coverPath, ext))
.Filter(f => _localFileSystem.FileExists(f))
.HeadOrNone();
}).Flatten();
}
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);
}
}
}
}
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -40,11 +41,15 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository, ISearchRepository searchRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IMediator mediator, IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<TelevisionFolderScanner> logger) : base( ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
imageCache, imageCache,
ffmpegProcessService,
tempFilePool,
logger) logger)
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
@@ -362,7 +367,7 @@ namespace ErsatzTV.Core.Metadata
async artworkFile => async artworkFile =>
{ {
ShowMetadata metadata = show.ShowMetadata.Head(); ShowMetadata metadata = show.ShowMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind); await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
}); });
return result; return result;
@@ -381,7 +386,7 @@ namespace ErsatzTV.Core.Metadata
async posterFile => async posterFile =>
{ {
SeasonMetadata metadata = season.SeasonMetadata.Head(); SeasonMetadata metadata = season.SeasonMetadata.Head();
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster); await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None, None);
}); });
return season; return season;
@@ -401,7 +406,7 @@ namespace ErsatzTV.Core.Metadata
{ {
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata) foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{ {
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail); await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail, None, 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);
}
}
}
+9 -2
View File
@@ -267,6 +267,8 @@ namespace ErsatzTV.Core.Scheduling
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, .IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
OtherVideo ov => await ov.MediaVersions.Map(v => v.Duration).HeadOrNone() OtherVideo ov => await ov.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, .IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
Song s => await s.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
_ => true _ => true
}; };
@@ -374,7 +376,8 @@ namespace ErsatzTV.Core.Scheduling
&& a.MediaItemId == collectionKey.MediaItemId); && a.MediaItemId == collectionKey.MediaItemId);
CollectionEnumeratorState state = maybeAnchor.Match( CollectionEnumeratorState state = maybeAnchor.Match(
anchor => anchor.EnumeratorState, anchor => anchor.EnumeratorState ??
(anchor.EnumeratorState = new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }),
() => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }); () => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0)) if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0))
@@ -435,7 +438,7 @@ namespace ErsatzTV.Core.Scheduling
private async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(CollectionKey collectionKey) private async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(CollectionKey collectionKey)
{ {
var result = new List<CollectionWithItems>(); List<CollectionWithItems> result;
if (collectionKey.MultiCollectionId != null) if (collectionKey.MultiCollectionId != null)
{ {
@@ -474,6 +477,10 @@ namespace ErsatzTV.Core.Scheduling
return ov.OtherVideoMetadata.HeadOrNone().Match( return ov.OtherVideoMetadata.HeadOrNone().Match(
ovm => ovm.Title ?? string.Empty, ovm => ovm.Title ?? string.Empty,
() => "[unknown video]"); () => "[unknown video]");
case Song s:
return s.SongMetadata.HeadOrNone().Match(
sm => sm.Title ?? string.Empty,
() => "[unknown song]");
default: default:
return string.Empty; return string.Empty;
} }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt.UnsafeValueAccess; using LanguageExt.UnsafeValueAccess;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -161,29 +162,13 @@ namespace ErsatzTV.Core.Scheduling
protected static TimeSpan DurationForMediaItem(MediaItem mediaItem) protected static TimeSpan DurationForMediaItem(MediaItem mediaItem)
{ {
MediaVersion version = mediaItem switch MediaVersion version = mediaItem.GetHeadVersion();
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
return version.Duration; return version.Duration;
} }
protected static List<MediaChapter> ChaptersForMediaItem(MediaItem mediaItem) protected static List<MediaChapter> ChaptersForMediaItem(MediaItem mediaItem)
{ {
MediaVersion version = mediaItem switch MediaVersion version = mediaItem.GetHeadVersion();
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
return version.Chapters; return version.Chapters;
} }
@@ -76,7 +76,8 @@ namespace ErsatzTV.Core.Scheduling
GuideGroup = nextState.NextGuideGroup, GuideGroup = nextState.NextGuideGroup,
FillerKind = scheduleItem.GuideMode == GuideMode.Filler FillerKind = scheduleItem.GuideMode == GuideMode.Filler
? FillerKind.Tail ? FillerKind.Tail
: FillerKind.None : FillerKind.None,
CustomTitle = scheduleItem.CustomTitle
}; };
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime); durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
@@ -99,7 +100,11 @@ namespace ErsatzTV.Core.Scheduling
nextState = nextState with nextState = nextState with
{ {
CurrentTime = itemEndTimeWithFiller, CurrentTime = itemEndTimeWithFiller,
NextGuideGroup = nextState.IncrementGuideGroup
// only bump guide group if we don't have a custom title
NextGuideGroup = string.IsNullOrWhiteSpace(scheduleItem.CustomTitle)
? nextState.IncrementGuideGroup
: nextState.NextGuideGroup
}; };
contentEnumerator.MoveNext(); contentEnumerator.MoveNext();
@@ -133,7 +138,10 @@ namespace ErsatzTV.Core.Scheduling
}; };
} }
nextState = nextState with { NextGuideGroup = nextState.DecrementGuideGroup }; if (playoutItems.Select(pi => pi.GuideGroup).Distinct().Count() != 1)
{
nextState = nextState with { NextGuideGroup = nextState.DecrementGuideGroup };
}
foreach (DateTimeOffset nextItemStart in durationUntil) foreach (DateTimeOffset nextItemStart in durationUntil)
{ {
@@ -35,6 +35,9 @@ namespace ErsatzTV.Core.Scheduling
IMediaCollectionEnumerator contentEnumerator = IMediaCollectionEnumerator contentEnumerator =
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)]; collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
ProgramScheduleItem peekScheduleItem =
_sortedScheduleItems[(nextState.ScheduleItemIndex + 1) % _sortedScheduleItems.Count];
while (contentEnumerator.Current.IsSome && nextState.CurrentTime < hardStop && willFinishInTime) while (contentEnumerator.Current.IsSome && nextState.CurrentTime < hardStop && willFinishInTime)
{ {
MediaItem mediaItem = contentEnumerator.Current.ValueUnsafe(); MediaItem mediaItem = contentEnumerator.Current.ValueUnsafe();
@@ -57,11 +60,9 @@ namespace ErsatzTV.Core.Scheduling
: FillerKind.None : FillerKind.None
}; };
ProgramScheduleItem peekScheduleItem =
_sortedScheduleItems[(nextState.ScheduleItemIndex + 1) % _sortedScheduleItems.Count];
DateTimeOffset peekScheduleItemStart = DateTimeOffset peekScheduleItemStart =
peekScheduleItem.StartType == StartType.Fixed peekScheduleItem.StartType == StartType.Fixed
? GetStartTimeAfter(nextState, peekScheduleItem) ? GetStartTimeAfter(nextState with { InFlood = false }, peekScheduleItem)
: DateTimeOffset.MaxValue; : DateTimeOffset.MaxValue;
DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller( DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller(

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