Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2021.1.3",
|
||||
"version": "2021.2.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
]
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 5.0.x
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
@@ -18,8 +18,11 @@ jobs:
|
||||
kind: windows
|
||||
target: win-x64
|
||||
- os: macos-latest
|
||||
kind: maxOS
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-latest
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Get the sources
|
||||
@@ -28,7 +31,7 @@ jobs:
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 5.0.x
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -44,7 +47,7 @@ jobs:
|
||||
release_name="ErsatzTV-$tag-${{ matrix.target }}"
|
||||
|
||||
# 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
|
||||
if [ "${{ matrix.target }}" == "win-x64" ]; then
|
||||
|
||||
+36
-1
@@ -5,6 +5,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
@@ -785,7 +817,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.3-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...HEAD
|
||||
[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
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
@@ -37,7 +36,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
CreateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
@@ -53,7 +53,6 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
(LocalLibrary existing, LocalLibrary incoming) = parameters;
|
||||
existing.Name = incoming.Name;
|
||||
|
||||
// toAdd
|
||||
var toAdd = incoming.Paths
|
||||
.Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path)))
|
||||
.ToList();
|
||||
@@ -77,7 +76,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
_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));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ namespace ErsatzTV.Application.MediaCards
|
||||
List<TelevisionEpisodeCardViewModel> EpisodeCards,
|
||||
List<ArtistCardViewModel> ArtistCards,
|
||||
List<MusicVideoCardViewModel> MusicVideoCards,
|
||||
List<OtherVideoCardViewModel> OtherVideoCards)
|
||||
List<OtherVideoCardViewModel> OtherVideoCards,
|
||||
List<SongCardViewModel> SongCards)
|
||||
{
|
||||
public bool UseCustomPlaybackOrder { get; set; }
|
||||
}
|
||||
|
||||
@@ -110,6 +110,16 @@ namespace ErsatzTV.Application.MediaCards
|
||||
otherVideoMetadata.OriginalTitle,
|
||||
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) =>
|
||||
new(
|
||||
artistMetadata.ArtistId,
|
||||
@@ -141,7 +151,9 @@ namespace ErsatzTV.Application.MediaCards
|
||||
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
|
||||
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
|
||||
.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 };
|
||||
|
||||
internal static ActorCardViewModel ProjectToViewModel(
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
GetCollectionCards request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
@@ -83,6 +83,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.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)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.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> ArtistIds,
|
||||
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.MusicVideoIds)
|
||||
.Append(request.OtherVideoIds)
|
||||
.Append(request.SongIds)
|
||||
.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
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await validation.Match(
|
||||
|
||||
@@ -208,6 +208,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -220,6 +221,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mm.Title == item.Title && mm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -241,6 +243,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeShowByGuid = await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -253,6 +256,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Title == item.Title && sm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -274,6 +278,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -286,6 +291,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
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.SeasonNumber == item.Season)
|
||||
.FirstOrDefaultAsync()
|
||||
@@ -308,6 +314,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(em => em.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -320,6 +327,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
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(em => em.Episode.Season.SeasonNumber == item.Season)
|
||||
.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 IMusicVideoFolderScanner _musicVideoFolderScanner;
|
||||
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
|
||||
private readonly ISongFolderScanner _songFolderScanner;
|
||||
private readonly ITelevisionFolderScanner _televisionFolderScanner;
|
||||
|
||||
public ScanLocalLibraryHandler(
|
||||
@@ -36,6 +37,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
ITelevisionFolderScanner televisionFolderScanner,
|
||||
IMusicVideoFolderScanner musicVideoFolderScanner,
|
||||
IOtherVideoFolderScanner otherVideoFolderScanner,
|
||||
ISongFolderScanner songFolderScanner,
|
||||
IEntityLocker entityLocker,
|
||||
IMediator mediator,
|
||||
ILogger<ScanLocalLibraryHandler> logger)
|
||||
@@ -46,6 +48,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
_televisionFolderScanner = televisionFolderScanner;
|
||||
_musicVideoFolderScanner = musicVideoFolderScanner;
|
||||
_otherVideoFolderScanner = otherVideoFolderScanner;
|
||||
_songFolderScanner = songFolderScanner;
|
||||
_entityLocker = entityLocker;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
@@ -67,7 +70,8 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
|
||||
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();
|
||||
sw.Start();
|
||||
@@ -117,6 +121,14 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
case LibraryMediaKind.Songs:
|
||||
await _songFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
ffmpegPath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
}
|
||||
|
||||
libraryPath.LastScan = DateTime.UtcNow;
|
||||
@@ -149,11 +161,12 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
}
|
||||
|
||||
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(
|
||||
(library, ffprobePath, libraryRefreshInterval) => new RequestParameters(
|
||||
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
|
||||
library,
|
||||
ffprobePath,
|
||||
ffmpegPath,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval));
|
||||
|
||||
@@ -170,6 +183,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
ffprobePath =>
|
||||
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() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
@@ -178,6 +198,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
private record RequestParameters(
|
||||
LocalLibrary LocalLibrary,
|
||||
string FFprobePath,
|
||||
string FFmpegPath,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ namespace ErsatzTV.Application.Playouts
|
||||
.Map(ovm => ovm.Title ?? string.Empty)
|
||||
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})")
|
||||
.IfNone("[unknown video]");
|
||||
case Song s:
|
||||
return s.SongMetadata.HeadOrNone()
|
||||
.Map(sm => sm.Title ?? string.Empty)
|
||||
.Map(t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? t : $"{s} ({playoutItem.ChapterTitle})")
|
||||
.IfNone("[unknown song]");
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
GetFuturePlayoutItemsById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
DateTime now = DateTimeOffset.Now.UtcDateTime;
|
||||
|
||||
@@ -57,6 +57,10 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.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.Finish >= now)
|
||||
.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.ArtistType, 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) =>
|
||||
_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> ArtistIds,
|
||||
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 ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -15,12 +15,12 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
|
||||
{
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public GetConcatProcessByChannelNumberHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
|
||||
+158
-32
@@ -1,15 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
@@ -31,15 +36,17 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
|
||||
public GetPlayoutItemProcessByChannelNumberHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
@@ -47,7 +54,9 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IArtistRepository artistRepository,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
IRuntimeInfo runtimeInfo,
|
||||
IImageCache imageCache,
|
||||
ITempFilePool tempFilePool)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
@@ -59,6 +68,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_televisionRepository = televisionRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
_imageCache = imageCache;
|
||||
_tempFilePool = tempFilePool;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
@@ -94,6 +105,15 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.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)
|
||||
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
|
||||
.BindT(ValidatePlayoutItemPath);
|
||||
@@ -106,18 +126,13 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
return await maybePlayoutItem.Match(
|
||||
async playoutItemWithPath =>
|
||||
{
|
||||
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem switch
|
||||
{
|
||||
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))
|
||||
};
|
||||
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
|
||||
|
||||
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
string videoPath = playoutItemWithPath.Path;
|
||||
MediaVersion videoVersion = version;
|
||||
|
||||
string audioPath = playoutItemWithPath.Path;
|
||||
MediaVersion audioVersion = version;
|
||||
|
||||
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements
|
||||
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId)
|
||||
@@ -125,12 +140,137 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
watermarkId => dbContext.ChannelWatermarks
|
||||
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
|
||||
|
||||
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
|
||||
{
|
||||
Option<string> drawtextFile = None;
|
||||
|
||||
videoVersion = new FallbackMediaVersion
|
||||
{
|
||||
Id = -1,
|
||||
Chapters = new List<MediaChapter>(),
|
||||
Width = 192,
|
||||
Height = 108,
|
||||
SampleAspectRatio = "1:1",
|
||||
Streams = new List<MediaStream>
|
||||
{
|
||||
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
|
||||
}
|
||||
};
|
||||
|
||||
string[] backgrounds =
|
||||
{
|
||||
"background_blank.png",
|
||||
"background_e.png",
|
||||
"background_t.png",
|
||||
"background_v.png"
|
||||
};
|
||||
|
||||
var random = new Random();
|
||||
|
||||
// use random ETV color by default
|
||||
string artworkPath = Path.Combine(
|
||||
FileSystemLayout.ResourcesCacheFolder,
|
||||
backgrounds[random.Next() % backgrounds.Length]);
|
||||
|
||||
// use thumbnail (cover art) if present
|
||||
foreach (SongMetadata metadata in song.SongMetadata)
|
||||
{
|
||||
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.DrawText);
|
||||
drawtextFile = fileName;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Artist))
|
||||
{
|
||||
sb.AppendLine(metadata.Artist);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
sb.AppendLine($"\"{metadata.Title}\"");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Album))
|
||||
{
|
||||
sb.AppendLine(metadata.Album);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(fileName, sb.ToString());
|
||||
|
||||
foreach (Artwork artwork in Optional(
|
||||
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
|
||||
{
|
||||
string customPath = _imageCache.GetPathForImage(
|
||||
artwork.Path,
|
||||
ArtworkKind.Thumbnail,
|
||||
Option<int>.None);
|
||||
|
||||
artworkPath = customPath;
|
||||
|
||||
// signal that we want to use cover art as watermark
|
||||
videoVersion = new CoverArtMediaVersion
|
||||
{
|
||||
Chapters = new List<MediaChapter>(),
|
||||
// always stretch cover art
|
||||
Width = 192,
|
||||
Height = 108,
|
||||
SampleAspectRatio = "1:1",
|
||||
Streams = new List<MediaStream>
|
||||
{
|
||||
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
videoPath = artworkPath;
|
||||
|
||||
videoVersion.MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = videoPath }
|
||||
};
|
||||
|
||||
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
|
||||
ffmpegPath,
|
||||
drawtextFile,
|
||||
channel,
|
||||
maybeGlobalWatermark,
|
||||
videoVersion,
|
||||
videoPath);
|
||||
|
||||
foreach (string si in maybeSongImage.RightToSeq())
|
||||
{
|
||||
videoPath = si;
|
||||
videoVersion = new BackgroundImageMediaVersion
|
||||
{
|
||||
Chapters = new List<MediaChapter>(),
|
||||
// song image has been pre-generated with correct size
|
||||
Height = channel.FFmpegProfile.Resolution.Height,
|
||||
Width = channel.FFmpegProfile.Resolution.Width,
|
||||
SampleAspectRatio = "1:1",
|
||||
Streams = new List<MediaStream>
|
||||
{
|
||||
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 },
|
||||
},
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = si }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
Process process = await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
version,
|
||||
playoutItemWithPath.Path,
|
||||
videoVersion,
|
||||
audioVersion,
|
||||
videoPath,
|
||||
audioPath,
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
playoutItemWithPath.PlayoutItem.FinishOffset,
|
||||
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
|
||||
@@ -271,14 +411,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.MapT(pi => pi.StartOffset - now),
|
||||
() => Option<TimeSpan>.None.AsTask());
|
||||
|
||||
MediaVersion version = item switch
|
||||
{
|
||||
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))
|
||||
};
|
||||
MediaVersion version = item.GetHeadVersion();
|
||||
|
||||
version.MediaFiles = await dbContext.MediaFiles
|
||||
.AsNoTracking()
|
||||
@@ -331,14 +464,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
|
||||
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
|
||||
{
|
||||
MediaVersion version = playoutItem.MediaItem switch
|
||||
{
|
||||
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))
|
||||
};
|
||||
MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
|
||||
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
string path = file.Path;
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.2.0" />
|
||||
<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.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<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.Extensions.Logging" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
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();
|
||||
}
|
||||
@@ -31,7 +31,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.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.IfSome(
|
||||
@@ -52,7 +52,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.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.IfSome(
|
||||
@@ -72,7 +72,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
.WithAlignedAudio(duration)
|
||||
.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.IfSome(
|
||||
@@ -123,7 +123,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
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.IfSome(
|
||||
@@ -274,11 +274,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
HorizontalMarginPercent = 7,
|
||||
VerticalMarginPercent = 5
|
||||
}),
|
||||
new Resolution { Width = 1920, Height = 1080 })
|
||||
new Resolution { Width = 1920, Height = 1080 },
|
||||
None)
|
||||
.WithDeinterlace(deinterlace)
|
||||
.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.IfSome(
|
||||
@@ -349,7 +350,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
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.IfSome(
|
||||
@@ -367,7 +368,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
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]")]
|
||||
[TestCase(
|
||||
true,
|
||||
@@ -379,13 +380,13 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
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]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
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]")]
|
||||
[TestCase(
|
||||
false,
|
||||
@@ -397,7 +398,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
false,
|
||||
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]")]
|
||||
public void Should_Return_NVENC_Video_Filter(
|
||||
bool deinterlace,
|
||||
@@ -420,7 +421,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
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.IfSome(
|
||||
@@ -542,7 +543,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
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.IfSome(
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
|
||||
{
|
||||
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-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -26,13 +26,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#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);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -49,14 +49,14 @@ live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldLimitSegments()
|
||||
{
|
||||
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-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -70,13 +70,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#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);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -90,14 +90,14 @@ live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
|
||||
live001138.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
|
||||
{
|
||||
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-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -111,7 +111,7 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#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,
|
||||
@@ -122,7 +122,7 @@ live001139.ts".Split(Environment.NewLine);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -140,14 +140,14 @@ live001138.ts
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldFilterOldSegments()
|
||||
{
|
||||
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-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -161,13 +161,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#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);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -178,14 +178,14 @@ live001139.ts".Split(Environment.NewLine);
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
|
||||
{
|
||||
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-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -200,13 +200,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#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);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -217,7 +217,15 @@ live001139.ts".Split(Environment.NewLine);
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
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.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
@@ -138,6 +139,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<IImageCache>().Object,
|
||||
new Mock<ITempFilePool>().Object,
|
||||
new Mock<ILogger<FFmpegProcessService>>().Object);
|
||||
|
||||
MediaVersion v = new MediaVersion();
|
||||
@@ -183,6 +185,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode = StreamingMode.TransportStream
|
||||
},
|
||||
v,
|
||||
v,
|
||||
file,
|
||||
file,
|
||||
now,
|
||||
now + TimeSpan.FromSeconds(5),
|
||||
@@ -237,7 +241,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
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[] hash = sha.ComputeHash(textData);
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
new Mock<ILogger<LocalStatisticsProvider>>().Object);
|
||||
|
||||
var input = new LocalStatisticsProvider.FFprobe(
|
||||
new LocalStatisticsProvider.FFprobeFormat("123.45"),
|
||||
new LocalStatisticsProvider.FFprobeFormat("123.45", null),
|
||||
new List<LocalStatisticsProvider.FFprobeStream>(),
|
||||
new List<LocalStatisticsProvider.FFprobeChapter>());
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -601,6 +603,8 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
new Mock<ISearchRepository>().Object,
|
||||
new Mock<ILibraryRepository>().Object,
|
||||
new Mock<IMediator>().Object,
|
||||
null,
|
||||
new Mock<ITempFilePool>().Object,
|
||||
new Mock<ILogger<MovieFolderScanner>>().Object
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,72 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
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]
|
||||
public void Should_Not_Have_Gap_Duration_Tail_Mode_None()
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
Movies = 1,
|
||||
Shows = 2,
|
||||
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 bool Default { get; set; }
|
||||
public bool Forced { get; set; }
|
||||
public bool AttachedPic { get; set; }
|
||||
public string PixelFormat { get; set; }
|
||||
public int BitsPerRawSample { 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; }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
Fallback = 0,
|
||||
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">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
@@ -10,15 +10,15 @@
|
||||
<PackageReference Include="Flurl" Version="3.0.2" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<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>
|
||||
|
||||
@@ -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,7 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -21,8 +23,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private IDisplaySize _resolution;
|
||||
private Option<IDisplaySize> _scaleToSize = None;
|
||||
private Option<ChannelWatermark> _watermark;
|
||||
private Option<int> _watermarkIndex;
|
||||
private string _pixelFormat;
|
||||
private string _videoEncoder;
|
||||
private Option<string> _drawtext;
|
||||
|
||||
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
|
||||
{
|
||||
@@ -60,22 +64,66 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputCodec(string codec)
|
||||
public FFmpegComplexFilterBuilder WithInputCodec(Option<string> maybeCodec)
|
||||
{
|
||||
_inputCodec = codec;
|
||||
foreach (string codec in maybeCodec)
|
||||
{
|
||||
_inputCodec = codec;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputPixelFormat(string pixelFormat)
|
||||
public FFmpegComplexFilterBuilder WithInputPixelFormat(Option<string> maybePixelFormat)
|
||||
{
|
||||
_pixelFormat = pixelFormat;
|
||||
foreach (string pixelFormat in maybePixelFormat)
|
||||
{
|
||||
_pixelFormat = pixelFormat;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution)
|
||||
public FFmpegComplexFilterBuilder WithWatermark(
|
||||
Option<ChannelWatermark> watermark,
|
||||
IDisplaySize resolution,
|
||||
Option<int> watermarkIndex)
|
||||
{
|
||||
_watermark = watermark;
|
||||
_resolution = resolution;
|
||||
_watermarkIndex = watermarkIndex;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithDrawtextFile(
|
||||
MediaVersion videoVersion,
|
||||
Option<string> drawtextFile)
|
||||
{
|
||||
foreach (string file in drawtextFile)
|
||||
{
|
||||
string effectiveFile = file;
|
||||
|
||||
if (videoVersion is FallbackMediaVersion or CoverArtMediaVersion)
|
||||
{
|
||||
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "OPTIKabel-Heavy.otf");
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
fontPath = fontPath
|
||||
.Replace(@"\", @"/\")
|
||||
.Replace(@":/", @"\\:/");
|
||||
|
||||
effectiveFile = effectiveFile
|
||||
.Replace(@"\", @"/\")
|
||||
.Replace(@":/", @"\\:/");
|
||||
}
|
||||
|
||||
// TODO: calculate by percent
|
||||
_drawtext =
|
||||
$"drawtext=fontfile={fontPath}:textfile={effectiveFile}:x=50:y=H-175:fontsize=36:fontcolor=white";
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -85,19 +133,19 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
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 videoLabel = $"0:{videoStreamIndex}";
|
||||
string audioLabel = audioStreamIndex.Match(index => $"0:{index}", () => "0:a");
|
||||
var videoLabel = $"{videoInput}:{(isSong ? "v" : videoStreamIndex.ToString())}";
|
||||
string audioLabel = audioStreamIndex.Match(index => $"{audioInput}:{index}", () => "0:a");
|
||||
|
||||
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
|
||||
bool isHardwareDecode = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => _inputCodec != "mpeg4",
|
||||
HardwareAccelerationKind.Nvenc => true,
|
||||
HardwareAccelerationKind.Qsv => true,
|
||||
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4",
|
||||
HardwareAccelerationKind.Nvenc => !isSong,
|
||||
HardwareAccelerationKind.Qsv => !isSong,
|
||||
_ => false
|
||||
};
|
||||
|
||||
@@ -105,7 +153,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
var videoFilterQueue = new List<string>();
|
||||
string watermarkPreprocess = string.Empty;
|
||||
string watermarkOverlay = string.Empty;
|
||||
|
||||
|
||||
if (_normalizeLoudness)
|
||||
{
|
||||
audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11");
|
||||
@@ -120,9 +168,37 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
|
||||
(_deinterlace || _scaleToSize.IsSome);
|
||||
if (usesHardwareFilters)
|
||||
|
||||
if (isSong)
|
||||
{
|
||||
videoFilterQueue.Add("hwupload");
|
||||
switch (acceleration)
|
||||
{
|
||||
case HardwareAccelerationKind.Qsv:
|
||||
videoFilterQueue.Add("format=nv12");
|
||||
break;
|
||||
case HardwareAccelerationKind.Vaapi:
|
||||
videoFilterQueue.Add("format=nv12|vaapi");
|
||||
break;
|
||||
default:
|
||||
videoFilterQueue.Add("format=yuv420p");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (usesHardwareFilters || isSong, acceleration)
|
||||
{
|
||||
case (true, HardwareAccelerationKind.Nvenc):
|
||||
videoFilterQueue.Add("hwupload_cuda");
|
||||
break;
|
||||
case (true, HardwareAccelerationKind.Qsv):
|
||||
videoFilterQueue.Add("hwupload=extra_hw_frames=64");
|
||||
break;
|
||||
case (true, HardwareAccelerationKind.Vaapi):
|
||||
videoFilterQueue.Add("hwupload");
|
||||
break;
|
||||
case (true, _) when usesHardwareFilters:
|
||||
videoFilterQueue.Add("hwupload");
|
||||
break;
|
||||
}
|
||||
|
||||
if (_deinterlace)
|
||||
@@ -163,8 +239,9 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" =>
|
||||
$"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}",
|
||||
_ 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"
|
||||
};
|
||||
|
||||
@@ -187,6 +264,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
||||
"format=p010le,format=nv12",
|
||||
HardwareAccelerationKind.Qsv when isSong => "format=nv12,format=yuv420p",
|
||||
_ when isSong => "format=yuv420p",
|
||||
_ => "format=nv12"
|
||||
};
|
||||
videoFilterQueue.Add(format);
|
||||
@@ -197,6 +276,16 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
videoFilterQueue.Add("setsar=1");
|
||||
}
|
||||
|
||||
if (videoOnly)
|
||||
{
|
||||
videoFilterQueue.Add("boxblur=40[b];[b]split[b1][b2];[b1]format=rgba,geq=r=0:g=0:b=0:a=120*(Y/H)[fg];[b2][fg]overlay=format=auto");
|
||||
}
|
||||
|
||||
if (isSong)
|
||||
{
|
||||
videoFilterQueue.Add("fps=30");
|
||||
}
|
||||
|
||||
foreach (ChannelWatermark watermark in _watermark)
|
||||
{
|
||||
string enable = watermark.Mode == ChannelWatermarkMode.Intermittent
|
||||
@@ -242,6 +331,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
|
||||
|
||||
foreach (string drawtext in _drawtext)
|
||||
{
|
||||
videoFilterQueue.Add(drawtext);
|
||||
}
|
||||
|
||||
string outputPixelFormat = null;
|
||||
|
||||
@@ -306,7 +400,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
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))
|
||||
{
|
||||
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");
|
||||
@@ -320,10 +419,21 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
|
||||
{
|
||||
complexFilter.Append(",hwupload");
|
||||
switch (isSong, acceleration)
|
||||
{
|
||||
case (true, HardwareAccelerationKind.Nvenc):
|
||||
complexFilter.Append(",hwupload_cuda");
|
||||
break;
|
||||
case (_, HardwareAccelerationKind.Qsv):
|
||||
complexFilter.Append(",format=yuv420p,hwupload=extra_hw_frames=64");
|
||||
break;
|
||||
default:
|
||||
complexFilter.Append(",hwupload");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
videoLabel = "[v]";
|
||||
complexFilter.Append(videoLabel);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
public FFmpegPlaybackSettings CalculateSettings(
|
||||
StreamingMode streamingMode,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
MediaVersion version,
|
||||
MediaStream videoStream,
|
||||
MediaVersion videoVersion,
|
||||
Option<MediaStream> videoStream,
|
||||
Option<MediaStream> audioStream,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset now,
|
||||
@@ -76,10 +76,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
case StreamingMode.TransportStream:
|
||||
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
|
||||
|
||||
if (NeedToScale(ffmpegProfile, version))
|
||||
if (NeedToScale(ffmpegProfile, videoVersion))
|
||||
{
|
||||
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, version);
|
||||
if (!scaledSize.IsSameSizeAs(version))
|
||||
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion);
|
||||
if (!scaledSize.IsSameSizeAs(videoVersion))
|
||||
{
|
||||
int fixedHeight = scaledSize.Height + scaledSize.Height % 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))
|
||||
{
|
||||
result.PadToDesiredResolution = true;
|
||||
@@ -98,32 +98,36 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.VideoTrackTimeScale = 90000;
|
||||
}
|
||||
|
||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
|
||||
foreach (MediaStream stream in videoStream.Where(s => s.AttachedPic == false))
|
||||
{
|
||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
|
||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, stream))
|
||||
{
|
||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
|
||||
|
||||
result.VideoDecoder =
|
||||
(result.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat) switch
|
||||
{
|
||||
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le") =>
|
||||
"h264",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
|
||||
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
|
||||
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
result.VideoCodec = "copy";
|
||||
result.VideoDecoder =
|
||||
(result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
|
||||
{
|
||||
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
|
||||
) =>
|
||||
"h264",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
|
||||
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
|
||||
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
result.VideoCodec = "copy";
|
||||
}
|
||||
}
|
||||
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
|
||||
@@ -150,7 +154,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.AudioCodec = "copy";
|
||||
}
|
||||
|
||||
if (version.VideoScanKind == VideoScanKind.Interlaced)
|
||||
if (videoVersion.VideoScanKind == VideoScanKind.Interlaced)
|
||||
{
|
||||
result.Deinterlace = true;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private string _vaapiDevice;
|
||||
private HardwareAccelerationKind _hwAccel;
|
||||
private string _outputPixelFormat;
|
||||
private bool _noAutoScale;
|
||||
private Option<int> _outputFramerate;
|
||||
|
||||
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
|
||||
{
|
||||
@@ -71,7 +73,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, string pixelFormat, string encoder)
|
||||
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, Option<string> pixelFormat, string encoder)
|
||||
{
|
||||
_hwAccel = hwAccel;
|
||||
|
||||
@@ -84,7 +86,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
|
||||
break;
|
||||
case HardwareAccelerationKind.Nvenc:
|
||||
string outputFormat = (encoder, pixelFormat) switch
|
||||
string outputFormat = (encoder, pixelFormat.IfNone("")) switch
|
||||
{
|
||||
("hevc_nvenc", "yuv420p10le") => "p010le",
|
||||
("h264_nvenc", "yuv420p10le") => "p010le",
|
||||
@@ -147,6 +149,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
_arguments.Add("-stream_loop");
|
||||
_arguments.Add("-1");
|
||||
|
||||
if (_hwAccel is HardwareAccelerationKind.Qsv or HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
_noAutoScale = true;
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
@@ -188,30 +195,66 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithWatermark(
|
||||
Option<ChannelWatermark> watermark,
|
||||
Option<string> maybePath,
|
||||
IDisplaySize resolution,
|
||||
bool isAnimated)
|
||||
Option<WatermarkOptions> watermarkOptions,
|
||||
IDisplaySize resolution)
|
||||
{
|
||||
foreach (string path in maybePath)
|
||||
foreach (WatermarkOptions options in watermarkOptions)
|
||||
{
|
||||
if (isAnimated)
|
||||
foreach (string path in options.ImagePath)
|
||||
{
|
||||
_arguments.Add("-ignore_loop");
|
||||
_arguments.Add("0");
|
||||
if (options.IsAnimated)
|
||||
{
|
||||
_arguments.Add("-ignore_loop");
|
||||
_arguments.Add("0");
|
||||
}
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(path);
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(
|
||||
options.Watermark,
|
||||
resolution,
|
||||
options.ImageStreamIndex);
|
||||
}
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(path);
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithDrawtextFile(
|
||||
MediaVersion videoVersion,
|
||||
Option<string> drawtextFile)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithDrawtextFile(
|
||||
videoVersion,
|
||||
drawtextFile);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInputCodec(string input, string decoder, string codec, string pixelFormat)
|
||||
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))
|
||||
{
|
||||
_arguments.Add("-c:v");
|
||||
@@ -223,7 +266,34 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithInputPixelFormat(pixelFormat);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add($"{input}");
|
||||
_arguments.Add(videoPath);
|
||||
|
||||
if (audioPath != videoPath)
|
||||
{
|
||||
WithSeek(maybeStart);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(audioPath);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithSongInput(
|
||||
string videoPath,
|
||||
Option<string> codec,
|
||||
Option<string> pixelFormat)
|
||||
{
|
||||
_noAutoScale = true;
|
||||
_outputFramerate = 30;
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
.WithInputCodec(codec)
|
||||
.WithInputPixelFormat(pixelFormat);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(videoPath);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -327,7 +397,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_arguments.Add($"{format}");
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
|
||||
{
|
||||
const int SEGMENT_SECONDS = 4;
|
||||
@@ -430,6 +500,18 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
});
|
||||
|
||||
_arguments.AddRange(arguments);
|
||||
|
||||
if (_noAutoScale)
|
||||
{
|
||||
_arguments.Add("-noautoscale");
|
||||
}
|
||||
|
||||
foreach (int framerate in _outputFramerate)
|
||||
{
|
||||
_arguments.Add("-r");
|
||||
_arguments.Add(framerate.ToString());
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -473,10 +555,23 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace);
|
||||
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(
|
||||
MediaStream videoStream,
|
||||
Option<MediaStream> maybeAudioStream,
|
||||
string videoPath,
|
||||
Option<string> audioPath,
|
||||
string videoCodec)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
|
||||
@@ -484,10 +579,33 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
int videoStreamIndex = videoStream.Index;
|
||||
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
|
||||
|
||||
var videoLabel = $"0:{videoStreamIndex}";
|
||||
var audioLabel = $"0:{maybeIndex.Match(i => i.ToString(), () => "a")}";
|
||||
var videoIndex = 0;
|
||||
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(
|
||||
filter =>
|
||||
{
|
||||
@@ -505,8 +623,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(videoLabel);
|
||||
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(audioLabel);
|
||||
foreach (string _ in audioPath)
|
||||
{
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(audioLabel);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegProcessService
|
||||
public class FFmpegProcessService : IFFmpegProcessService
|
||||
{
|
||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
private readonly ILogger<FFmpegProcessService> _logger;
|
||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||
|
||||
@@ -23,11 +24,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
|
||||
IFFmpegStreamSelector ffmpegStreamSelector,
|
||||
IImageCache imageCache,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<FFmpegProcessService> logger)
|
||||
{
|
||||
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
|
||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||
_imageCache = imageCache;
|
||||
_tempFilePool = tempFilePool;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -35,8 +38,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
MediaVersion version,
|
||||
string path,
|
||||
MediaVersion videoVersion,
|
||||
MediaVersion audioVersion,
|
||||
string videoPath,
|
||||
string audioPath,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset finish,
|
||||
DateTimeOffset now,
|
||||
@@ -48,13 +53,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
TimeSpan inPoint,
|
||||
TimeSpan outPoint)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
version,
|
||||
videoVersion,
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
start,
|
||||
@@ -62,26 +67,30 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
inPoint,
|
||||
outPoint);
|
||||
|
||||
(Option<ChannelWatermark> maybeWatermark, Option<string> maybeWatermarkPath) =
|
||||
GetWatermarkOptions(channel, globalWatermark);
|
||||
|
||||
bool isAnimated = await maybeWatermarkPath.Match(
|
||||
p => _imageCache.IsAnimated(p),
|
||||
() => Task.FromResult(false));
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithVaapiDriver(vaapiDriver, vaapiDevice)
|
||||
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration, videoStream.PixelFormat, playbackSettings.VideoCodec)
|
||||
.WithHardwareAcceleration(
|
||||
playbackSettings.HardwareAcceleration,
|
||||
videoStream.PixelFormat,
|
||||
playbackSettings.VideoCodec)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithSeek(playbackSettings.StreamSeek)
|
||||
.WithInfiniteLoop(fillerKind == FillerKind.Fallback)
|
||||
.WithInputCodec(path, playbackSettings.VideoDecoder, videoStream.Codec, videoStream.PixelFormat)
|
||||
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
|
||||
.WithInputCodec(
|
||||
playbackSettings.StreamSeek,
|
||||
fillerKind == FillerKind.Fallback,
|
||||
videoPath,
|
||||
audioPath,
|
||||
playbackSettings.VideoDecoder,
|
||||
videoStream.Codec,
|
||||
videoStream.PixelFormat)
|
||||
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
|
||||
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None)
|
||||
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
|
||||
|
||||
playbackSettings.ScaledSize.Match(
|
||||
@@ -96,7 +105,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
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
|
||||
.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithBlackBars(channel.FFmpegProfile.Resolution)
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
else if (playbackSettings.Deinterlace)
|
||||
{
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
else
|
||||
{
|
||||
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
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
return builder.WithHls(channel.Number, version)
|
||||
return builder.WithHls(channel.Number, videoVersion)
|
||||
.WithRealtimeOutput(hlsRealtime)
|
||||
.Build();
|
||||
default:
|
||||
@@ -196,14 +225,130 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.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 async Task<Either<BaseError, string>> GenerateSongImage(
|
||||
string ffmpegPath,
|
||||
Option<string> drawtextFile,
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
MediaVersion videoVersion,
|
||||
string videoPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string outputFile = _tempFilePool.GetNextTempFile(TempFileCategory.SongBackground);
|
||||
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
|
||||
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
|
||||
|
||||
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)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat)
|
||||
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
|
||||
.WithDrawtextFile(videoVersion, drawtextFile);
|
||||
|
||||
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) =>
|
||||
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)
|
||||
{
|
||||
if (videoVersion is BackgroundImageMediaVersion)
|
||||
{
|
||||
return new WatermarkOptions(None, None, None, false);
|
||||
}
|
||||
|
||||
Option<ChannelWatermark> watermarkOverride = videoVersion is FallbackMediaVersion or CoverArtMediaVersion
|
||||
? new ChannelWatermark
|
||||
{
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
HorizontalMarginPercent = 3,
|
||||
VerticalMarginPercent = 5,
|
||||
Location = ChannelWatermarkLocation.BottomRight,
|
||||
Size = ChannelWatermarkSize.Scaled,
|
||||
WidthPercent = 25,
|
||||
Opacity = 100
|
||||
}
|
||||
: None;
|
||||
|
||||
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
|
||||
channel.FFmpegProfile.NormalizeVideo)
|
||||
{
|
||||
if (videoVersion is CoverArtMediaVersion)
|
||||
{
|
||||
return new WatermarkOptions(
|
||||
watermarkOverride,
|
||||
videoVersion.MediaFiles.Head().Path,
|
||||
0,
|
||||
false);
|
||||
}
|
||||
|
||||
// check for channel watermark
|
||||
if (channel.Watermark != null)
|
||||
{
|
||||
@@ -214,13 +359,23 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
channel.Watermark.Image,
|
||||
ArtworkKind.Watermark,
|
||||
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:
|
||||
Option<string> maybeChannelPath = channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.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:
|
||||
throw new NotSupportedException("Unsupported watermark image source");
|
||||
}
|
||||
@@ -236,22 +391,30 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
watermark.Image,
|
||||
ArtworkKind.Watermark,
|
||||
Option<int>.None);
|
||||
return new WatermarkOptions(watermark, customPath);
|
||||
return new WatermarkOptions(
|
||||
await watermarkOverride.IfNoneAsync(watermark),
|
||||
customPath,
|
||||
None,
|
||||
await _imageCache.IsAnimated(customPath));
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
Option<string> maybeChannelPath = channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.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:
|
||||
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,9 @@
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public enum TempFileCategory
|
||||
{
|
||||
DrawText = 0,
|
||||
SongBackground = 1,
|
||||
CoverArt = 2
|
||||
}
|
||||
}
|
||||
@@ -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 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");
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
Task<Either<BaseError, string>> GenerateSongImage(
|
||||
string ffmpegPath,
|
||||
Option<string> drawtextFile,
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
MediaVersion videoVersion,
|
||||
string videoPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface ITempFilePool
|
||||
{
|
||||
string GetNextTempFile(TempFileCategory category);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
MovieMetadata GetFallbackMetadata(Movie movie);
|
||||
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
|
||||
Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo);
|
||||
Option<SongMetadata> GetFallbackMetadata(Song song);
|
||||
string GetSortTitle(string title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
|
||||
Task<bool> RefreshTagMetadata(Song song, string ffprobePath);
|
||||
Task<bool> RefreshFallbackMetadata(Movie movie);
|
||||
Task<bool> RefreshFallbackMetadata(Episode episode);
|
||||
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
|
||||
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
|
||||
Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo);
|
||||
Task<bool> RefreshFallbackMetadata(Song song);
|
||||
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 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, 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,10 @@ namespace ErsatzTV.Core.Iptv
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Title ?? string.Empty)
|
||||
.IfNone("[unknown song]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +104,20 @@ namespace ErsatzTV.Core.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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
@@ -266,6 +280,43 @@ namespace ErsatzTV.Core.Metadata
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private Option<SongMetadata> GetSongMetadata(string path, SongMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
string folder = Path.GetDirectoryName(path);
|
||||
if (folder == null)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
string libraryPath = metadata.Song.LibraryPath.Path;
|
||||
string parent = Optional(Directory.GetParent(libraryPath)).Match(
|
||||
di => di.FullName,
|
||||
() => libraryPath);
|
||||
|
||||
string diff = Path.GetRelativePath(parent, folder);
|
||||
|
||||
var tags = diff.Split(Path.DirectorySeparatorChar)
|
||||
.Map(t => new Tag { Name = t })
|
||||
.ToList();
|
||||
|
||||
metadata.Artwork = new List<Artwork>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = tags;
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
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.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -17,7 +21,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
public static readonly List<string> VideoFileExtensions = new()
|
||||
{
|
||||
".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()
|
||||
@@ -41,6 +50,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList();
|
||||
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
@@ -52,12 +63,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_metadataRepository = metadataRepository;
|
||||
_imageCache = imageCache;
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_tempFilePool = tempFilePool;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -68,14 +83,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
try
|
||||
{
|
||||
MediaVersion version = mediaItem.Item switch
|
||||
{
|
||||
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))
|
||||
};
|
||||
MediaVersion version = mediaItem.Item.GetHeadVersion();
|
||||
|
||||
string path = version.MediaFiles.Head().Path;
|
||||
|
||||
@@ -108,7 +116,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind)
|
||||
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind, Option<string> ffmpegPath)
|
||||
{
|
||||
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
|
||||
|
||||
@@ -125,6 +133,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
|
||||
|
||||
// if ffmpeg path is passed, we want to convert to png
|
||||
foreach (string path in ffmpegPath)
|
||||
{
|
||||
string tempName = _tempFilePool.GetNextTempFile(TempFileCategory.CoverArt);
|
||||
using Process process = _ffmpegProcessService.ConvertToPng(path, artworkFile, tempName);
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
artworkFile = tempName;
|
||||
}
|
||||
|
||||
Either<BaseError, string> maybeCacheName =
|
||||
await _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -23,6 +24,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IEpisodeNfoReader _episodeNfoReader;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<LocalMetadataProvider> _logger;
|
||||
@@ -31,6 +33,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly ISongRepository _songRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public LocalMetadataProvider(
|
||||
@@ -40,9 +43,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
IArtistRepository artistRepository,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
IOtherVideoRepository otherVideoRepository,
|
||||
ISongRepository songRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IEpisodeNfoReader episodeNfoReader,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILogger<LocalMetadataProvider> logger)
|
||||
{
|
||||
_metadataRepository = metadataRepository;
|
||||
@@ -51,9 +56,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
_artistRepository = artistRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_otherVideoRepository = otherVideoRepository;
|
||||
_songRepository = songRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localFileSystem = localFileSystem;
|
||||
_episodeNfoReader = episodeNfoReader;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -130,6 +137,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata => ApplyMetadataUpdate(musicVideo, metadata),
|
||||
() => 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) =>
|
||||
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
|
||||
|
||||
@@ -144,6 +157,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata => ApplyMetadataUpdate(otherVideo, metadata),
|
||||
() => 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) =>
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
|
||||
metadata => ApplyMetadataUpdate(musicVideo, metadata),
|
||||
@@ -182,6 +200,95 @@ 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>()
|
||||
};
|
||||
|
||||
// TODO: check for cover artwork, and use for watermark if not embedded
|
||||
// maybe add album as entity rather than string?
|
||||
|
||||
if (tags.TryGetValue(MetadataFormatTag.Album, out string album))
|
||||
{
|
||||
result.Album = album;
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
// TODO: split genres? or is this only ever one?
|
||||
result.Genres.Add(new Genre { Name = genre });
|
||||
}
|
||||
|
||||
if (tags.TryGetValue(MetadataFormatTag.Title, out string title))
|
||||
{
|
||||
result.Title = title;
|
||||
result.OriginalTitle = 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)
|
||||
{
|
||||
var updated = false;
|
||||
@@ -661,6 +768,49 @@ namespace ErsatzTV.Core.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)
|
||||
{
|
||||
@@ -931,7 +1081,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
if (existing is not MusicVideoMetadata)
|
||||
if (existing is not MusicVideoMetadata and not SongMetadata)
|
||||
{
|
||||
foreach (Actor actor in existing.Actors
|
||||
.Filter(a => incoming.Actors.All(a2 => a2.Name != a.Name))
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
@@ -34,15 +35,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
try
|
||||
{
|
||||
string filePath = mediaItem switch
|
||||
{
|
||||
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))
|
||||
};
|
||||
|
||||
string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
|
||||
return await RefreshStatistics(ffprobePath, mediaItem, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -76,16 +69,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)
|
||||
{
|
||||
MediaVersion mediaItemVersion = mediaItem switch
|
||||
{
|
||||
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))
|
||||
};
|
||||
MediaVersion mediaItemVersion = mediaItem.GetHeadVersion();
|
||||
|
||||
bool durationChange = mediaItemVersion.Duration != version.Duration;
|
||||
|
||||
@@ -221,6 +261,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
stream.Default = videoStream.disposition.@default == 1;
|
||||
stream.Forced = videoStream.disposition.forced == 1;
|
||||
stream.AttachedPic = videoStream.disposition.attached_pic == 1;
|
||||
}
|
||||
|
||||
version.Streams.Add(stream);
|
||||
@@ -290,7 +331,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
last.EndTime = version.Duration;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return version;
|
||||
},
|
||||
_ => new MediaVersion
|
||||
@@ -312,12 +353,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
// ReSharper disable InconsistentNaming
|
||||
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 FFprobeFormatTags(
|
||||
string title,
|
||||
string artist,
|
||||
string album,
|
||||
string track,
|
||||
string genre,
|
||||
string date);
|
||||
|
||||
public record FFprobeStream(
|
||||
int index,
|
||||
string codec_name,
|
||||
|
||||
@@ -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 ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -40,8 +41,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
ISearchRepository searchRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediator mediator,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<MovieFolderScanner> logger)
|
||||
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
|
||||
: base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
ffmpegProcessService,
|
||||
tempFilePool,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_movieRepository = movieRepository;
|
||||
@@ -229,7 +239,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind);
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind, None);
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -41,11 +42,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediator mediator,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<MusicVideoFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
ffmpegProcessService,
|
||||
tempFilePool,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
@@ -217,7 +222,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async artworkFile =>
|
||||
{
|
||||
ArtistMetadata metadata = artist.ArtistMetadata.Head();
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind);
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None);
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -380,7 +385,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async thumbnailFile =>
|
||||
{
|
||||
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail);
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None);
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -39,11 +40,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
ISearchRepository searchRepository,
|
||||
IOtherVideoRepository otherVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<OtherVideoFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
ffmpegProcessService,
|
||||
tempFilePool,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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, ffprobePath, 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 ffprobePath,
|
||||
string ffmpegPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Song song = result.Item;
|
||||
await LocateThumbnail(song).Match(
|
||||
async thumbnailFile =>
|
||||
{
|
||||
SongMetadata metadata = song.SongMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, ffmpegPath);
|
||||
},
|
||||
() => Task.CompletedTask); // TODO: check for embedded artwork
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -40,11 +41,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
ISearchRepository searchRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediator mediator,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ITempFilePool tempFilePool,
|
||||
ILogger<TelevisionFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
ffmpegProcessService,
|
||||
tempFilePool,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
@@ -362,7 +367,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async artworkFile =>
|
||||
{
|
||||
ShowMetadata metadata = show.ShowMetadata.Head();
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind);
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None);
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -381,7 +386,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async posterFile =>
|
||||
{
|
||||
SeasonMetadata metadata = season.SeasonMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster);
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None);
|
||||
});
|
||||
|
||||
return season;
|
||||
@@ -401,7 +406,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
|
||||
{
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail, None);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -267,6 +267,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
OtherVideo ov => await ov.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
Song s => await s.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
_ => true
|
||||
};
|
||||
|
||||
@@ -374,7 +376,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
&& a.MediaItemId == collectionKey.MediaItemId);
|
||||
|
||||
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 });
|
||||
|
||||
if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0))
|
||||
@@ -435,7 +438,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
|
||||
private async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(CollectionKey collectionKey)
|
||||
{
|
||||
var result = new List<CollectionWithItems>();
|
||||
List<CollectionWithItems> result;
|
||||
|
||||
if (collectionKey.MultiCollectionId != null)
|
||||
{
|
||||
@@ -474,6 +477,10 @@ namespace ErsatzTV.Core.Scheduling
|
||||
return ov.OtherVideoMetadata.HeadOrNone().Match(
|
||||
ovm => ovm.Title ?? string.Empty,
|
||||
() => "[unknown video]");
|
||||
case Song s:
|
||||
return s.SongMetadata.HeadOrNone().Match(
|
||||
sm => sm.Title ?? string.Empty,
|
||||
() => "[unknown song]");
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -161,29 +162,13 @@ namespace ErsatzTV.Core.Scheduling
|
||||
|
||||
protected static TimeSpan DurationForMediaItem(MediaItem mediaItem)
|
||||
{
|
||||
MediaVersion version = mediaItem switch
|
||||
{
|
||||
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))
|
||||
};
|
||||
|
||||
MediaVersion version = mediaItem.GetHeadVersion();
|
||||
return version.Duration;
|
||||
}
|
||||
|
||||
protected static List<MediaChapter> ChaptersForMediaItem(MediaItem mediaItem)
|
||||
{
|
||||
MediaVersion version = mediaItem switch
|
||||
{
|
||||
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))
|
||||
};
|
||||
|
||||
MediaVersion version = mediaItem.GetHeadVersion();
|
||||
return version.Chapters;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
GuideGroup = nextState.NextGuideGroup,
|
||||
FillerKind = scheduleItem.GuideMode == GuideMode.Filler
|
||||
? FillerKind.Tail
|
||||
: FillerKind.None
|
||||
: FillerKind.None,
|
||||
CustomTitle = scheduleItem.CustomTitle
|
||||
};
|
||||
|
||||
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
|
||||
@@ -99,7 +100,11 @@ namespace ErsatzTV.Core.Scheduling
|
||||
nextState = nextState with
|
||||
{
|
||||
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();
|
||||
@@ -133,7 +138,10 @@ namespace ErsatzTV.Core.Scheduling
|
||||
};
|
||||
}
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.DecrementGuideGroup };
|
||||
if (playoutItems.Select(pi => pi.GuideGroup).Distinct().Count() != 1)
|
||||
{
|
||||
nextState = nextState with { NextGuideGroup = nextState.DecrementGuideGroup };
|
||||
}
|
||||
|
||||
foreach (DateTimeOffset nextItemStart in durationUntil)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class SongConfiguration : IEntityTypeConfiguration<Song>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Song> builder)
|
||||
{
|
||||
builder.ToTable("Song");
|
||||
|
||||
builder.HasMany(m => m.SongMetadata)
|
||||
.WithOne(m => m.Song)
|
||||
.HasForeignKey(m => m.SongId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(m => m.MediaVersions)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class SongMetadataConfiguration : IEntityTypeConfiguration<SongMetadata>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SongMetadata> builder)
|
||||
{
|
||||
builder.ToTable("SongMetadata");
|
||||
|
||||
builder.HasMany(mm => mm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
.HasForeignKey(pi => pi.PlayoutId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.OwnsOne(c => c.Anchor);
|
||||
builder.OwnsOne(p => p.Anchor)
|
||||
.ToTable("PlayoutAnchor");
|
||||
|
||||
builder.HasMany(p => p.ProgramScheduleAnchors)
|
||||
.WithOne(a => a.Playout)
|
||||
|
||||
+1
-2
@@ -10,8 +10,7 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
builder.ToTable("PlayoutProgramScheduleAnchor");
|
||||
|
||||
builder.OwnsOne(a => a.EnumeratorState)
|
||||
.WithOwner();
|
||||
builder.OwnsOne(a => a.EnumeratorState).ToTable("CollectionEnumeratorState");
|
||||
|
||||
builder.HasOne(i => i.Collection)
|
||||
.WithMany()
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE A.ArtistMetadataId IS NULL AND A.EpisodeMetadataId IS NULL
|
||||
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL
|
||||
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL
|
||||
AND A.ChannelId IS NULL
|
||||
AND A.SongMetadataId IS NULL AND A.ChannelId IS NULL
|
||||
AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)")
|
||||
.Map(result => result.ToList());
|
||||
|
||||
|
||||
@@ -80,6 +80,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Collections
|
||||
.Include(c => c.CollectionItems)
|
||||
.OrderBy(c => c.Id)
|
||||
@@ -44,7 +44,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<List<MediaItem>> GetItems(int collectionId)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var result = new List<MediaItem>();
|
||||
|
||||
@@ -55,13 +55,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
result.AddRange(await GetArtistItems(dbContext, collectionId));
|
||||
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetSongItems(dbContext, collectionId));
|
||||
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
|
||||
public async Task<List<MediaItem>> GetMultiCollectionItems(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var result = new List<MediaItem>();
|
||||
|
||||
@@ -81,6 +82,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
result.AddRange(await GetArtistItems(dbContext, collectionId));
|
||||
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetSongItems(dbContext, collectionId));
|
||||
}
|
||||
|
||||
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
|
||||
@@ -94,7 +96,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<List<MediaItem>> GetSmartCollectionItems(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var result = new List<MediaItem>();
|
||||
|
||||
@@ -145,6 +147,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Map(i => i.Id)
|
||||
.ToList();
|
||||
result.AddRange(await GetOtherVideoItems(dbContext, otherVideoIds));
|
||||
|
||||
var songIds = searchResults.Items
|
||||
.Filter(i => i.Type == SearchIndex.SongType)
|
||||
.Map(i => i.Id)
|
||||
.ToList();
|
||||
result.AddRange(await GetSongItems(dbContext, songIds));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -152,7 +160,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var result = new List<CollectionWithItems>();
|
||||
|
||||
@@ -441,6 +449,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Filter(m => otherVideoIds.Contains(m.Id))
|
||||
.ToListAsync();
|
||||
|
||||
private async Task<List<Song>> GetSongItems(TvContext dbContext, int collectionId)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT s.Id FROM CollectionItem ci
|
||||
INNER JOIN Song s ON s.Id = ci.MediaItemId
|
||||
WHERE ci.CollectionId = @CollectionId",
|
||||
new { CollectionId = collectionId });
|
||||
|
||||
return await GetSongItems(dbContext, ids);
|
||||
}
|
||||
|
||||
private static Task<List<Song>> GetSongItems(TvContext dbContext, IEnumerable<int> songIds) =>
|
||||
dbContext.Songs
|
||||
.Include(m => m.SongMetadata)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.Chapters)
|
||||
.Filter(m => songIds.Contains(m.Id))
|
||||
.ToListAsync();
|
||||
|
||||
private async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
|
||||
@@ -248,6 +248,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
SongMetadata => _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO Artwork (ArtworkKind, SongMetadataId, DateAdded, DateUpdated, Path)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<Option<MediaItem>> GetItemToIndex(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.MediaItems
|
||||
.AsNoTracking()
|
||||
.Include(mi => mi.LibraryPath)
|
||||
@@ -101,6 +101,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => (mi as Song).SongMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as Song).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => mi.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(mi => mi.Id)
|
||||
@@ -139,7 +143,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class SongRepository : ISongRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public SongRepository(IDbConnection dbConnection, IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbConnection = dbConnection;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<Song>>> GetOrAdd(
|
||||
LibraryPath libraryPath,
|
||||
string path)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<Song> maybeExisting = await dbContext.Songs
|
||||
.AsNoTracking()
|
||||
.Include(s => s.SongMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(s => s.SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(s => s.SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.Include(s => s.SongMetadata)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.Include(s => s.SongMetadata)
|
||||
.Include(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(s => s.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(s => s.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
mediaItem =>
|
||||
Right<BaseError, MediaItemScanResult<Song>>(
|
||||
new MediaItemScanResult<Song>(mediaItem) { IsAdded = false }).AsTask(),
|
||||
async () => await AddSong(dbContext, libraryPath.Id, path));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<string>> FindSongPaths(LibraryPath libraryPath) =>
|
||||
_dbConnection.QueryAsync<string>(
|
||||
@"SELECT MF.Path
|
||||
FROM MediaFile MF
|
||||
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
|
||||
INNER JOIN Song O on MV.SongId = O.Id
|
||||
INNER JOIN MediaItem MI on O.Id = MI.Id
|
||||
WHERE MI.LibraryPathId = @LibraryPathId",
|
||||
new { LibraryPathId = libraryPath.Id });
|
||||
|
||||
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT O.Id
|
||||
FROM Song O
|
||||
INNER JOIN MediaItem MI on O.Id = MI.Id
|
||||
INNER JOIN MediaVersion MV on O.Id = MV.SongId
|
||||
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
|
||||
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
|
||||
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
foreach (int songId in ids)
|
||||
{
|
||||
Song song = await dbContext.Songs.FindAsync(songId);
|
||||
if (song != null)
|
||||
{
|
||||
dbContext.Songs.Remove(song);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public Task<bool> AddGenre(SongMetadata metadata, Genre genre) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Genre (Name, SongMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<bool> AddTag(SongMetadata metadata, Tag tag) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Tag (Name, SongMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public async Task<List<SongMetadata>> GetSongsForCards(List<int> ids)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.SongMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(ovm => ids.Contains(ovm.SongId))
|
||||
.Include(ovm => ovm.Song)
|
||||
.Include(ovm => ovm.Artwork)
|
||||
.OrderBy(ovm => ovm.SortTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<Song>>> AddSong(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var song = new Song
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
},
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
await dbContext.Songs.AddAsync(song);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(song).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(song.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<Song>(song) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }
|
||||
public DbSet<OtherVideo> OtherVideos { get; set; }
|
||||
public DbSet<OtherVideoMetadata> OtherVideoMetadata { get; set; }
|
||||
public DbSet<Song> Songs { get; set; }
|
||||
public DbSet<SongMetadata> SongMetadata { get; set; }
|
||||
public DbSet<Show> Shows { get; set; }
|
||||
public DbSet<ShowMetadata> ShowMetadata { get; set; }
|
||||
public DbSet<Season> Seasons { get; set; }
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.0.90" />
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00015" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00015" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00015" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.11">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.11" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -18,12 +18,12 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
{
|
||||
public class ImageCache : IImageCache
|
||||
{
|
||||
private static readonly SHA1CryptoServiceProvider Crypto;
|
||||
private static readonly SHA1 Crypto;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<ImageCache> _logger;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
static ImageCache() => Crypto = new SHA1CryptoServiceProvider();
|
||||
static ImageCache() => Crypto = SHA1.Create();
|
||||
|
||||
public ImageCache(ILocalFileSystem localFileSystem, IMemoryCache memoryCache, ILogger<ImageCache> logger)
|
||||
{
|
||||
|
||||
+3671
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Move_ToOwnedTables : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Playout_ProgramScheduleItem_Anchor_NextScheduleItemId",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Playout_Anchor_NextScheduleItemId",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnumeratorState_Index",
|
||||
table: "PlayoutProgramScheduleAnchor");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnumeratorState_Seed",
|
||||
table: "PlayoutProgramScheduleAnchor");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_DurationFinish",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_InDurationFiller",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_MultipleRemaining",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_NextGuideGroup",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_NextScheduleItemId",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_NextStart",
|
||||
table: "Playout");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CollectionEnumeratorState",
|
||||
columns: table => new
|
||||
{
|
||||
PlayoutProgramScheduleAnchorId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Seed = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Index = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CollectionEnumeratorState", x => x.PlayoutProgramScheduleAnchorId);
|
||||
table.ForeignKey(
|
||||
name: "FK_CollectionEnumeratorState_PlayoutProgramScheduleAnchor_PlayoutProgramScheduleAnchorId",
|
||||
column: x => x.PlayoutProgramScheduleAnchorId,
|
||||
principalTable: "PlayoutProgramScheduleAnchor",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlayoutAnchor",
|
||||
columns: table => new
|
||||
{
|
||||
PlayoutId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
NextScheduleItemId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
NextStart = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
MultipleRemaining = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
DurationFinish = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
InFlood = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
InDurationFiller = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
NextGuideGroup = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlayoutAnchor", x => x.PlayoutId);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayoutAnchor_Playout_PlayoutId",
|
||||
column: x => x.PlayoutId,
|
||||
principalTable: "Playout",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlayoutAnchor_ProgramScheduleItem_NextScheduleItemId",
|
||||
column: x => x.NextScheduleItemId,
|
||||
principalTable: "ProgramScheduleItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayoutAnchor_NextScheduleItemId",
|
||||
table: "PlayoutAnchor",
|
||||
column: "NextScheduleItemId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CollectionEnumeratorState");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlayoutAnchor");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EnumeratorState_Index",
|
||||
table: "PlayoutProgramScheduleAnchor",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EnumeratorState_Seed",
|
||||
table: "PlayoutProgramScheduleAnchor",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "Anchor_DurationFinish",
|
||||
table: "Playout",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Anchor_InDurationFiller",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Anchor_MultipleRemaining",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Anchor_NextGuideGroup",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Anchor_NextScheduleItemId",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "Anchor_NextStart",
|
||||
table: "Playout",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Playout_Anchor_NextScheduleItemId",
|
||||
table: "Playout",
|
||||
column: "Anchor_NextScheduleItemId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Playout_ProgramScheduleItem_Anchor_NextScheduleItemId",
|
||||
table: "Playout",
|
||||
column: "Anchor_NextScheduleItemId",
|
||||
principalTable: "ProgramScheduleItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3671
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_LocalLibrary_Songs : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// create local songs library
|
||||
migrationBuilder.Sql(
|
||||
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
|
||||
SELECT 'Songs', 5, Id FROM
|
||||
(SELECT LMS.Id FROM LocalMediaSource LMS LIMIT 1)");
|
||||
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_rowid())");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+3826
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_Songs_SongMetadata : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Tag",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Studio",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "MetadataGuid",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongId",
|
||||
table: "MediaVersion",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Genre",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Artwork",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SongMetadataId",
|
||||
table: "Actor",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Song",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Song", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Song_MediaItem_Id",
|
||||
column: x => x.Id,
|
||||
principalTable: "MediaItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SongMetadata",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SongId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
MetadataKind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", nullable: true),
|
||||
OriginalTitle = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SortTitle = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Year = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
ReleaseDate = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SongMetadata", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SongMetadata_Song_SongId",
|
||||
column: x => x.SongId,
|
||||
principalTable: "Song",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tag_SongMetadataId",
|
||||
table: "Tag",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Studio_SongMetadataId",
|
||||
table: "Studio",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MetadataGuid_SongMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaVersion_SongId",
|
||||
table: "MediaVersion",
|
||||
column: "SongId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Genre_SongMetadataId",
|
||||
table: "Genre",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Artwork_SongMetadataId",
|
||||
table: "Artwork",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Actor_SongMetadataId",
|
||||
table: "Actor",
|
||||
column: "SongMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SongMetadata_SongId",
|
||||
table: "SongMetadata",
|
||||
column: "SongId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_SongMetadata_SongMetadataId",
|
||||
table: "Actor",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Artwork_SongMetadata_SongMetadataId",
|
||||
table: "Artwork",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MediaVersion_Song_SongId",
|
||||
table: "MediaVersion",
|
||||
column: "SongId",
|
||||
principalTable: "Song",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MetadataGuid_SongMetadata_SongMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tag_SongMetadata_SongMetadataId",
|
||||
table: "Tag",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_SongMetadata_SongMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Artwork_SongMetadata_SongMetadataId",
|
||||
table: "Artwork");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MediaVersion_Song_SongId",
|
||||
table: "MediaVersion");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MetadataGuid_SongMetadata_SongMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tag_SongMetadata_SongMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SongMetadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Song");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tag_SongMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Studio_SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MetadataGuid_SongMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MediaVersion_SongId",
|
||||
table: "MediaVersion");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Genre_SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Artwork_SongMetadataId",
|
||||
table: "Artwork");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Actor_SongMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongId",
|
||||
table: "MediaVersion");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Artwork");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SongMetadataId",
|
||||
table: "Actor");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3829
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_StreamAttachedPic : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AttachedPic",
|
||||
table: "MediaStream",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AttachedPic",
|
||||
table: "MediaStream");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3843
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_SongAlbum : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Album",
|
||||
table: "SongMetadata",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Artist",
|
||||
table: "SongMetadata",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Date",
|
||||
table: "SongMetadata",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Track",
|
||||
table: "SongMetadata",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Album",
|
||||
table: "SongMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Artist",
|
||||
table: "SongMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Date",
|
||||
table: "SongMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Track",
|
||||
table: "SongMetadata");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_SongMetadata_SongMetadataId",
|
||||
table: "Genre",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_SongMetadata_SongMetadataId",
|
||||
table: "Studio",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3843
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_SongMetadata : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
|
||||
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 5)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 5");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE SongMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
|
||||
(SELECT LF.Id FROM LibraryFolder LF INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id INNER JOIN Library L on LP.LibraryId = L.Id WHERE MediaKind = 5)");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user