Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73887706ed | ||
|
|
abc103308b | ||
|
|
3773bbec19 | ||
|
|
e223d6a43f | ||
|
|
8369111e31 | ||
|
|
35ba2bab2c | ||
|
|
094ed71ad0 | ||
|
|
89e24b2b78 | ||
|
|
848795af32 | ||
|
|
56f94f489a | ||
|
|
475dc7660b | ||
|
|
db3dfbd446 | ||
|
|
b4c9cdbbfa | ||
|
|
7f84933c0b | ||
|
|
1e35e9a5b0 | ||
|
|
7edf6f5d13 | ||
|
|
919325033d | ||
|
|
2cb5252320 | ||
|
|
015232fad6 | ||
|
|
af51b790b6 | ||
|
|
9195ef7878 | ||
|
|
dfc4c7a284 | ||
|
|
a6b15f68c9 | ||
|
|
0edfb71f8d | ||
|
|
21b90a1b6c | ||
|
|
1582f5dd15 | ||
|
|
fd3b72525d | ||
|
|
55d1871d94 | ||
|
|
a90eb2d4de | ||
|
|
ed3f1b1dad | ||
|
|
8e08ff059f | ||
|
|
fb8c3a0453 | ||
|
|
e45fb67769 | ||
|
|
3a40d6ce77 | ||
|
|
ac048b72ae | ||
|
|
852728c816 | ||
|
|
096f2d42e8 | ||
|
|
1b29e252ff | ||
|
|
a4dc9bfb31 | ||
|
|
184c21a91b | ||
|
|
6ea3191cf8 | ||
|
|
d487bbca08 | ||
|
|
05034b47e2 | ||
|
|
b0c85b6478 | ||
|
|
f1356563da | ||
|
|
c0aad028a8 | ||
|
|
dae06ec0ef | ||
|
|
72f452fd36 | ||
|
|
aaf832c0b6 | ||
|
|
08a18daf23 | ||
|
|
90c1c61a09 | ||
|
|
053db71d44 |
@@ -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"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
+45
-36
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace ErsatzTV.CommandLine
|
|
||||||
{
|
|
||||||
public class Config
|
|
||||||
{
|
|
||||||
public string ServerUrl { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
foreach (string codec in maybeCodec)
|
||||||
{
|
{
|
||||||
_inputCodec = codec;
|
_inputCodec = codec;
|
||||||
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public FFmpegComplexFilterBuilder WithInputPixelFormat(string pixelFormat)
|
public FFmpegComplexFilterBuilder WithInputPixelFormat(Option<string> maybePixelFormat)
|
||||||
|
{
|
||||||
|
foreach (string pixelFormat in maybePixelFormat)
|
||||||
{
|
{
|
||||||
_pixelFormat = pixelFormat;
|
_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
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,13 +158,45 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
}
|
}
|
||||||
|
|
||||||
_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)
|
||||||
{
|
{
|
||||||
|
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");
|
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
|
||||||
@@ -238,6 +330,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;
|
||||||
|
|
||||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None &&
|
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None &&
|
||||||
@@ -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,7 +417,18 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
|
|
||||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
|
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
|
||||||
{
|
{
|
||||||
|
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");
|
complexFilter.Append(",hwupload");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +98,20 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
result.VideoTrackTimeScale = 90000;
|
result.VideoTrackTimeScale = 90000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (MediaStream stream in videoStream.Where(s => s.AttachedPic == false))
|
||||||
|
{
|
||||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
|
NeedToNormalizeVideoCodec(ffmpegProfile, stream))
|
||||||
{
|
{
|
||||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||||
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
|
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",
|
"h264",
|
||||||
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
|
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
|
||||||
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
|
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
|
||||||
@@ -125,6 +128,7 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +190,26 @@ 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)
|
||||||
|
{
|
||||||
|
if (options.IsAnimated)
|
||||||
{
|
{
|
||||||
_arguments.Add("-ignore_loop");
|
_arguments.Add("-ignore_loop");
|
||||||
_arguments.Add("0");
|
_arguments.Add("0");
|
||||||
@@ -204,14 +218,45 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
_arguments.Add("-i");
|
_arguments.Add("-i");
|
||||||
_arguments.Add(path);
|
_arguments.Add(path);
|
||||||
|
|
||||||
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution);
|
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(
|
||||||
|
options.Watermark,
|
||||||
|
resolution,
|
||||||
|
options.ImageStreamIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,9 +526,22 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
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);
|
||||||
|
|
||||||
|
foreach (string _ in audioPath)
|
||||||
|
{
|
||||||
_arguments.Add("-map");
|
_arguments.Add("-map");
|
||||||
_arguments.Add(audioLabel);
|
_arguments.Add(audioLabel);
|
||||||
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ErsatzTV.Core.FFmpeg
|
||||||
|
{
|
||||||
|
public enum TempFileCategory
|
||||||
|
{
|
||||||
|
Subtitle = 0,
|
||||||
|
SongBackground = 1,
|
||||||
|
CoverArt = 2,
|
||||||
|
CachedArtwork = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,6 +214,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))
|
||||||
{
|
{
|
||||||
Option<ShowMetadata> maybeMetadata =
|
Option<ShowMetadata> maybeMetadata =
|
||||||
@@ -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))
|
||||||
@@ -267,6 +281,43 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -662,6 +765,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)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -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);
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (playoutItems.Select(pi => pi.GuideGroup).Distinct().Count() != 1)
|
||||||
|
{
|
||||||
nextState = nextState with { NextGuideGroup = nextState.DecrementGuideGroup };
|
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
Reference in New Issue
Block a user