Compare commits
37 Commits
v0.6.1-bet
...
v0.6.5-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f2b3da4b | ||
|
|
866049543c | ||
|
|
40ed4b8b0e | ||
|
|
b43d08ca67 | ||
|
|
5e7e386108 | ||
|
|
4176df9940 | ||
|
|
de2ef959fe | ||
|
|
b53cfebac1 | ||
|
|
6895b9cc6b | ||
|
|
c60d6e46f1 | ||
|
|
c66d190174 | ||
|
|
5e8da591be | ||
|
|
9c02a6738b | ||
|
|
5ed0184bca | ||
|
|
ae64ca4a93 | ||
|
|
c47099895e | ||
|
|
a2529febba | ||
|
|
521e0ba8b3 | ||
|
|
ee0efac9be | ||
|
|
bfe7635489 | ||
|
|
aa1735f024 | ||
|
|
8deae983c7 | ||
|
|
f349646703 | ||
|
|
5003e80500 | ||
|
|
940d9cd6b5 | ||
|
|
197c166789 | ||
|
|
d114db091e | ||
|
|
3204da8e43 | ||
|
|
100eb14408 | ||
|
|
025017ace5 | ||
|
|
6a690c7c10 | ||
|
|
dd7f77751c | ||
|
|
0c13b8ef1a | ||
|
|
c6ca58ab97 | ||
|
|
0846fc1d96 | ||
|
|
e41dd68ee0 | ||
|
|
0a92996da8 |
19
.github/workflows/artifacts.yml
vendored
19
.github/workflows/artifacts.yml
vendored
@@ -41,18 +41,18 @@ jobs:
|
||||
target: osx-arm64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
@@ -108,15 +108,16 @@ jobs:
|
||||
--icon "ErsatzTV.app" 200 190 \
|
||||
--hide-extension "ErsatzTV.app" \
|
||||
--app-drop-link 600 185 \
|
||||
--skip-jenkins \
|
||||
"ErsatzTV.dmg" \
|
||||
"ErsatzTV.app/"
|
||||
|
||||
- name: Notarize
|
||||
shell: bash
|
||||
run: |
|
||||
curl -o gon.zip -L -s "https://github.com/mitchellh/gon/releases/latest/download/gon_macos.zip"
|
||||
unzip -o -q gon.zip
|
||||
./gon -log-level=debug -log-json ./gon.json
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
gon -log-level=debug -log-json ./gon.json
|
||||
env:
|
||||
AC_USERNAME: ${{ secrets.ac_username }}
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
@@ -167,17 +168,17 @@ jobs:
|
||||
target: win-x64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
|
||||
33
.github/workflows/docker.yml
vendored
33
.github/workflows/docker.yml
vendored
@@ -39,32 +39,36 @@ jobs:
|
||||
path: 'vaapi/'
|
||||
suffix: '-vaapi'
|
||||
qemu: false
|
||||
- name: arm32v7
|
||||
path: 'arm32v7/'
|
||||
suffix: '-arm'
|
||||
qemu: true
|
||||
- name: arm64
|
||||
path: 'arm64/'
|
||||
suffix: '-arm64'
|
||||
qemu: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
if: ${{ matrix.qemu == true }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
id: docker-buildx
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username }}
|
||||
password: ${{ secrets.docker_hub_access_token }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -75,10 +79,10 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name != 'arm64' }}
|
||||
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -91,3 +95,18 @@ jobs:
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm64' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
platforms: 'linux/arm/v7'
|
||||
build-args: |
|
||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm32v7' }}
|
||||
|
||||
5
.github/workflows/docs.yml
vendored
5
.github/workflows/docs.yml
vendored
@@ -3,13 +3,16 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docs/**
|
||||
- mkdocs.yml
|
||||
jobs:
|
||||
build:
|
||||
name: Deploy docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Deploy docs
|
||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
||||
|
||||
4
.github/workflows/pr.yml
vendored
4
.github/workflows/pr.yml
vendored
@@ -10,10 +10,10 @@ jobs:
|
||||
os: [ windows-latest, ubuntu-latest, macos-latest ]
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
|
||||
4
.github/workflows/vue-lint.yml
vendored
4
.github/workflows/vue-lint.yml
vendored
@@ -7,10 +7,10 @@ jobs:
|
||||
steps:
|
||||
# Checkout the current repo
|
||||
- name: Checkout current repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
# Setup NodeJS version 14
|
||||
- name: Setup NodeJS V14.x.x
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
# CD into the current client directory and lint and build the client
|
||||
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -5,6 +5,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.5-beta] - 2022-08-02
|
||||
### Fixed
|
||||
- Fix database initializer; fresh installs with v0.6.4-beta are missing some config data and should upgrade
|
||||
|
||||
## [0.6.4-beta] - 2022-07-28
|
||||
### Fixed
|
||||
- Fix subtitle stream selection when subtitle language is different than audio language
|
||||
- Fix bug with unsupported AAC channel layouts
|
||||
- Fix NVIDIA second-gen maxwell capabilities detection
|
||||
- Return distinct search results for episodes and other videos that have the same title
|
||||
- For example, two other videos both named `Trailer` would previously have displayed as one item in search results
|
||||
- Fix schedules that would begin to repeat the same content in the same order after a couple of days
|
||||
|
||||
### Added
|
||||
- Add `640x480` resolution
|
||||
|
||||
## [0.6.3-beta] - 2022-07-04
|
||||
### Fixed
|
||||
- Maintain stream continuity when playout is rebuilt for a channel that is actively being streamed
|
||||
- Properly apply changes to episode title, sort title, outline and plot from Plex
|
||||
- Fix search index for other videos and songs
|
||||
- In previous versions, some libraries would incorrectly display only one item
|
||||
- Properly display old versions of renamed items in trash
|
||||
|
||||
### Added
|
||||
- Add `Minimum Log Level` option to `Settings` page
|
||||
- Other methods of configuring the log level will no longer work
|
||||
|
||||
## [0.6.2-beta] - 2022-06-18
|
||||
### Fixed
|
||||
- Fix content repeating for up to a minute near the top of every hour
|
||||
- Check whether hardware-accelerated hevc codecs are supported by the NVIDIA card
|
||||
- Software codecs will be used if they are unsupported by the NVIDIA card
|
||||
- Fix sorting of channel contents in EPG
|
||||
- Fix Jellyfin admin user id sync
|
||||
- Ignore disabled admins and admins who do not have access to all libraries
|
||||
|
||||
### Added
|
||||
- Add 32-bit `arm` docker tags (`develop-arm` and `latest-arm`)
|
||||
|
||||
### Changed
|
||||
- Regularly delete old segments from transcode folder while content is actively transcoding
|
||||
- This should help reduce required disk space
|
||||
- To further minimize required disk space, set `Work-Ahead HLS Segmenter Limit` to `0` in `Settings`
|
||||
|
||||
## [0.6.1-beta] - 2022-06-03
|
||||
### Fixed
|
||||
- Fix Jellyfin show library paging
|
||||
@@ -1235,7 +1280,11 @@ 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.6.1-beta...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.5-beta...HEAD
|
||||
[0.6.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.4-beta...v0.6.5-beta
|
||||
[0.6.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.3-beta...v0.6.4-beta
|
||||
[0.6.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.2-beta...v0.6.3-beta
|
||||
[0.6.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.1-beta...v0.6.2-beta
|
||||
[0.6.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.0-beta...v0.6.1-beta
|
||||
[0.6.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
|
||||
[0.5.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...v0.5.8-beta
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateGeneralSettings(GeneralSettingsViewModel GeneralSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Serilog.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly LoggingLevelSwitch _loggingLevelSwitch;
|
||||
|
||||
public UpdateGeneralSettingsHandler(
|
||||
LoggingLevelSwitch loggingLevelSwitch,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_loggingLevelSwitch = loggingLevelSwitch;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateGeneralSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.GeneralSettings);
|
||||
|
||||
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.MinimumLogLevel);
|
||||
_loggingLevelSwitch.MinimumLevel = generalSettings.MinimumLogLevel;
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GeneralSettingsViewModel
|
||||
{
|
||||
public LogEventLevel MinimumLogLevel { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetGeneralSettings : IRequest<GeneralSettingsViewModel>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, GeneralSettingsViewModel>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetGeneralSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LogEventLevel> maybeLogLevel =
|
||||
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
|
||||
|
||||
return new GeneralSettingsViewModel
|
||||
{
|
||||
MinimumLogLevel = await maybeLogLevel.IfNoneAsync(LogEventLevel.Information)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
|
||||
@@ -69,7 +69,7 @@ public class
|
||||
|
||||
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
|
||||
|
||||
Command process = _ffmpegProcessService.ResizeImage(
|
||||
Command process = await _ffmpegProcessService.ResizeImage(
|
||||
ffmpegPath,
|
||||
originalPath,
|
||||
withExtension,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.Libraries;
|
||||
public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public MoveLocalLibraryPathHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MoveLocalLibraryPathHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -35,7 +39,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, parameters => MovePath(dbContext, parameters));
|
||||
return await validation.Apply(parameters => MovePath(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
|
||||
@@ -57,7 +61,10 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
foreach (MediaItem mediaItem in maybeMediaItem)
|
||||
{
|
||||
_logger.LogInformation("Moving item at {Path}", await GetPath(dbContext, mediaItem));
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { mediaItem });
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<MediaItem> { mediaItem });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -17,12 +18,13 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
|
||||
public AddTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<AddTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -14,20 +15,23 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public DeleteTraktListHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<DeleteTraktListHandler> logger,
|
||||
IEntityLocker entityLocker)
|
||||
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
: base(traktApiClient, searchRepository, searchIndex, fallbackMetadataProvider, logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
@@ -38,8 +42,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
{
|
||||
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 LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
}
|
||||
@@ -56,7 +59,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
dbContext.TraktLists.Remove(traktList);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, mediaItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, mediaItemIds);
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -18,11 +19,17 @@ public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||
|
||||
public MatchTraktListItemsHandler(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<MatchTraktListItemsHandler> logger,
|
||||
IEntityLocker entityLocker) : base(traktApiClient, searchRepository, searchIndex, logger)
|
||||
IEntityLocker entityLocker) : base(
|
||||
traktApiClient,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_entityLocker = entityLocker;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Trakt;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
@@ -13,18 +14,21 @@ namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public abstract class TraktCommandBase
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
protected TraktCommandBase(
|
||||
ITraktApiClient traktApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger logger)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
TraktApiClient = traktApiClient;
|
||||
}
|
||||
@@ -158,7 +162,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids.ToList());
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids.ToList());
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -37,7 +38,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, BuildPlayout request, Playout playout)
|
||||
@@ -45,7 +46,12 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
try
|
||||
{
|
||||
await _playoutBuilder.Build(playout, request.Mode);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
|
||||
// let any active segmenter processes know that the playout has been modified
|
||||
// and therefore the segmenter may need to seek into the next item instead of
|
||||
// starting at the beginning (if already working ahead)
|
||||
bool hasChanges = await dbContext.SaveChangesAsync() > 0;
|
||||
if (request.Mode != PlayoutBuildMode.Continue && hasChanges)
|
||||
{
|
||||
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Humanizer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -12,16 +13,18 @@ namespace ErsatzTV.Application.Search;
|
||||
public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Unit>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RebuildSearchIndexHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public RebuildSearchIndexHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger<RebuildSearchIndexHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
@@ -29,14 +32,19 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
_searchRepository = searchRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Initializing search index");
|
||||
|
||||
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
|
||||
|
||||
await _searchIndex.Initialize(_localFileSystem, _configElementRepository);
|
||||
|
||||
_logger.LogInformation("Done initializing search index");
|
||||
|
||||
if (!indexFolderExists ||
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
|
||||
_searchIndex.Version)
|
||||
@@ -44,7 +52,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
_logger.LogInformation("Migrating search index to version {Version}", _searchIndex.Version);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await _searchIndex.Rebuild(_searchRepository);
|
||||
await _searchIndex.Rebuild(_searchRepository, _fallbackMetadataProvider);
|
||||
|
||||
await _configElementRepository.Upsert(ConfigElementKey.SearchIndexVersion, _searchIndex.Version);
|
||||
sw.Stop();
|
||||
|
||||
@@ -3,10 +3,14 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
@@ -14,7 +18,9 @@ namespace ErsatzTV.Application.Search;
|
||||
public class
|
||||
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes, TelevisionEpisodeCardResultsViewModel>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
@@ -27,7 +33,9 @@ public class
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
@@ -35,6 +43,8 @@ public class
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
|
||||
@@ -52,8 +62,40 @@ public class
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<EpisodeMetadata> episodes = await _televisionRepository
|
||||
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList());
|
||||
var episodeIds = searchResult.Items.Map(i => i.Id).ToList();
|
||||
|
||||
List<EpisodeMetadata> episodes = await _televisionRepository.GetEpisodesForCards(episodeIds);
|
||||
|
||||
// try to load fallback metadata for episodes that have none
|
||||
// this handles an edge case of trashed items with no saved metadata
|
||||
var missingEpisodes = episodeIds.Except(episodes.Map(e => e.EpisodeId)).ToList();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
foreach (int missingEpisodeId in missingEpisodes)
|
||||
{
|
||||
Option<Episode> maybeEpisode = await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(e => e.MediaFiles)
|
||||
.Include(e => e.Season)
|
||||
.ThenInclude(s => s.SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(e => e.Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.SelectOneAsync(e => e.Id, e => e.Id == missingEpisodeId);
|
||||
|
||||
foreach (Episode episode in maybeEpisode)
|
||||
{
|
||||
foreach (EpisodeMetadata headMetadata in _fallbackMetadataProvider.GetFallbackMetadata(episode)
|
||||
.HeadOrNone())
|
||||
{
|
||||
headMetadata.Episode = episode;
|
||||
episode.EpisodeMetadata = new List<EpisodeMetadata> { headMetadata };
|
||||
episodes.Add(headMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var items = new List<TelevisionEpisodeCardViewModel>();
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
private readonly object _sync = new();
|
||||
private string _channelNumber;
|
||||
private bool _firstProcess;
|
||||
private bool _hasWrittenSegments;
|
||||
private DateTimeOffset _lastAccess;
|
||||
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
|
||||
private bool _seekNextItem;
|
||||
private Option<int> _targetFramerate;
|
||||
private Timer _timer;
|
||||
private DateTimeOffset _transcodedUntil;
|
||||
@@ -65,7 +68,19 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
try
|
||||
{
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
return maybeLines.Map(input => _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input));
|
||||
foreach (string[] input in maybeLines)
|
||||
{
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input);
|
||||
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
|
||||
{
|
||||
DeleteOldSegments(trimResult);
|
||||
_lastDelete = DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
return trimResult;
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -73,7 +88,11 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
public void PlayoutUpdated() => _firstProcess = true;
|
||||
public void PlayoutUpdated()
|
||||
{
|
||||
_firstProcess = true;
|
||||
_seekNextItem = true;
|
||||
}
|
||||
|
||||
public async Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken)
|
||||
{
|
||||
@@ -190,7 +209,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, _firstProcess, cancellationToken);
|
||||
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, cancellationToken);
|
||||
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
|
||||
|
||||
var request = new GetPlayoutItemProcessByChannelNumber(
|
||||
@@ -237,6 +256,13 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
_logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber);
|
||||
_transcodedUntil = processModel.Until;
|
||||
_firstProcess = false;
|
||||
if (_seekNextItem)
|
||||
{
|
||||
_firstProcess = true;
|
||||
_seekNextItem = false;
|
||||
}
|
||||
|
||||
_hasWrittenSegments = true;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
@@ -281,6 +307,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
if (commandResult.ExitCode == 0)
|
||||
{
|
||||
_firstProcess = false;
|
||||
if (_seekNextItem)
|
||||
{
|
||||
_firstProcess = true;
|
||||
_seekNextItem = false;
|
||||
}
|
||||
|
||||
_hasWrittenSegments = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -334,33 +368,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
lines);
|
||||
await WritePlaylist(trimResult.Playlist, cancellationToken);
|
||||
|
||||
// delete old segments
|
||||
var allSegments = Directory.GetFiles(
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
|
||||
"live*.ts")
|
||||
.Map(
|
||||
file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
|
||||
return new Segment(file, sequenceNumber);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
|
||||
// if (toDelete.Count > 0)
|
||||
// {
|
||||
// _logger.LogDebug(
|
||||
// "Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
|
||||
// toDelete.Map(s => s.SequenceNumber).Min(),
|
||||
// toDelete.Map(s => s.SequenceNumber).Max(),
|
||||
// trimResult.Sequence);
|
||||
// }
|
||||
|
||||
foreach (Segment segment in toDelete)
|
||||
{
|
||||
File.Delete(segment.File);
|
||||
}
|
||||
DeleteOldSegments(trimResult);
|
||||
|
||||
PlaylistStart = trimResult.PlaylistStart;
|
||||
}
|
||||
@@ -371,10 +379,40 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteOldSegments(TrimPlaylistResult trimResult)
|
||||
{
|
||||
// delete old segments
|
||||
var allSegments = Directory.GetFiles(
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
|
||||
"live*.ts")
|
||||
.Map(
|
||||
file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
|
||||
return new Segment(file, sequenceNumber);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
|
||||
if (toDelete.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
|
||||
toDelete.Map(s => s.SequenceNumber).Min(),
|
||||
toDelete.Map(s => s.SequenceNumber).Max(),
|
||||
trimResult.Sequence);
|
||||
}
|
||||
|
||||
foreach (Segment segment in toDelete)
|
||||
{
|
||||
File.Delete(segment.File);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> GetPtsOffset(
|
||||
IMediator mediator,
|
||||
string channelNumber,
|
||||
bool firstProcess,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
@@ -382,8 +420,8 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
long result = 0;
|
||||
|
||||
// the first process always starts at zero
|
||||
if (firstProcess)
|
||||
// if we haven't yet written any segments, start at zero
|
||||
if (!_hasWrittenSegments)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
Command process = _ffmpegProcessService.ConcatChannel(
|
||||
Command process = await _ffmpegProcessService.ConcatChannel(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.7.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.2.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,6 +10,8 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -17,6 +19,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
@@ -302,6 +305,10 @@ public class TranscodingTests
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<ITempFilePool>().Object,
|
||||
new FakeNvidiaCapabilitiesFactory(),
|
||||
// new HardwareCapabilitiesFactory(
|
||||
// new MemoryCache(new MemoryCacheOptions()),
|
||||
// LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()),
|
||||
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
|
||||
|
||||
var v = new MediaVersion
|
||||
@@ -573,6 +580,14 @@ public class TranscodingTests
|
||||
subtitles.HeadOrNone().AsTask();
|
||||
}
|
||||
|
||||
private class FakeNvidiaCapabilitiesFactory : IHardwareCapabilitiesFactory
|
||||
{
|
||||
public Task<IHardwareCapabilities> GetHardwareCapabilities(
|
||||
string ffmpegPath,
|
||||
HardwareAccelerationMode hardwareAccelerationMode) =>
|
||||
Task.FromResult<IHardwareCapabilities>(new NvidiaHardwareCapabilities(61, string.Empty));
|
||||
}
|
||||
|
||||
private static string ExecutableName(string baseName) =>
|
||||
OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Fakes;
|
||||
|
||||
@@ -42,8 +41,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<Show>>>
|
||||
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
|
||||
public Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(int libraryPathId, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber) =>
|
||||
@@ -74,36 +72,11 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
||||
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
|
||||
PlexLibrary library,
|
||||
PlexShow item) =>
|
||||
public Task<bool> UpdateTitles(EpisodeMetadata metadata, string title, string sortTitle) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<bool> UpdateOutline(EpisodeMetadata metadata, string outline) => throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> GetOrAddPlexEpisode(
|
||||
PlexLibrary library,
|
||||
PlexEpisode item) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexEpisode episode, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<bool> UpdatePlot(EpisodeMetadata metadata, string plot) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -229,6 +229,38 @@ public class JellyfinPathReplacementServiceTests
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task JellyfinLinux_To_EtvLinux_UncPath()
|
||||
{
|
||||
var replacements = new List<JellyfinPathReplacement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder",
|
||||
LocalPath = @"/mnt/something else/Some Shared Folder",
|
||||
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
|
||||
}
|
||||
};
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
|
||||
|
||||
var service = new JellyfinPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
|
||||
|
||||
string result = await service.GetReplacementJellyfinPath(
|
||||
0,
|
||||
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Throw_For_Null_JellyfinPath()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,13 @@ public class FallbackMetadataProviderTests
|
||||
"Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv",
|
||||
1,
|
||||
2)]
|
||||
[TestCase("Awesome Show - s01.e02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01.E02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s01_e02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01_E02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - s01xe02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - S01XE02.mkv", 1, 2)]
|
||||
[TestCase("Awesome Show - 1x02.mkv", 1, 2)]
|
||||
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, int season, int episode)
|
||||
{
|
||||
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Tests.Fakes;
|
||||
@@ -622,7 +623,8 @@ public class MovieFolderScannerTests
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<ISearchRepository>().Object,
|
||||
new Mock<ICachingSearchRepository>().Object,
|
||||
new Mock<IFallbackMetadataProvider>().Object,
|
||||
new Mock<ILibraryRepository>().Object,
|
||||
_mediaItemRepository.Object,
|
||||
new Mock<IMediator>().Object,
|
||||
@@ -642,7 +644,8 @@ public class MovieFolderScannerTests
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<ISearchRepository>().Object,
|
||||
new Mock<ICachingSearchRepository>().Object,
|
||||
new Mock<IFallbackMetadataProvider>().Object,
|
||||
new Mock<ILibraryRepository>().Object,
|
||||
_mediaItemRepository.Object,
|
||||
new Mock<IMediator>().Object,
|
||||
|
||||
@@ -2235,7 +2235,7 @@ public class PlayoutBuilderTests
|
||||
DateTimeOffset start2 = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result2 = await builder.Build(playout, PlayoutBuildMode.Continue, start2, finish2);
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed;
|
||||
|
||||
@@ -2244,6 +2244,57 @@ public class PlayoutBuilderTests
|
||||
result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ShuffleFlood_Should_MaintainRandomSeed_MultipleDays()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>();
|
||||
for (int i = 1; i <= 25; i++)
|
||||
{
|
||||
mediaItems.Add(TestMovie(i, TimeSpan.FromMinutes(55), DateTime.Today.AddHours(i)));
|
||||
}
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Shuffle);
|
||||
DateTimeOffset start = HoursAfterMidnight(0).AddSeconds(5);
|
||||
DateTimeOffset finish = start + TimeSpan.FromDays(2);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(53);
|
||||
result.ProgramScheduleAnchors.Count.Should().Be(2);
|
||||
|
||||
result.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).Should().BeTrue();
|
||||
PlayoutProgramScheduleAnchor lastCheckpoint = result.ProgramScheduleAnchors
|
||||
.OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue)
|
||||
.First();
|
||||
lastCheckpoint.EnumeratorState.Seed.Should().BeGreaterThan(0);
|
||||
lastCheckpoint.EnumeratorState.Index.Should().Be(3);
|
||||
|
||||
// we need to mess up the ordering to trigger the problematic behavior
|
||||
// this simulates the way the rows are loaded with EF
|
||||
PlayoutProgramScheduleAnchor oldest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).Last();
|
||||
PlayoutProgramScheduleAnchor newest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).First();
|
||||
|
||||
result.ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>
|
||||
{
|
||||
oldest,
|
||||
newest
|
||||
};
|
||||
|
||||
int firstSeedValue = lastCheckpoint.EnumeratorState.Seed;
|
||||
|
||||
DateTimeOffset start2 = start.AddHours(1);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromDays(2);
|
||||
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
PlayoutProgramScheduleAnchor continueAnchor =
|
||||
result2.ProgramScheduleAnchors.First(x => x.AnchorDate is null);
|
||||
int secondSeedValue = continueAnchor.EnumeratorState.Seed;
|
||||
|
||||
// the continue anchor should have the same seed as the most recent (last) checkpoint from the first run
|
||||
firstSeedValue.Should().Be(secondSeedValue);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
|
||||
{
|
||||
|
||||
244
ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs
Normal file
244
ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
using Dapper;
|
||||
using Destructurama;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
[Explicit]
|
||||
public class ScheduleIntegrationTests
|
||||
{
|
||||
public ScheduleIntegrationTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
|
||||
.WriteTo.Console()
|
||||
.Destructure.UsingAttributes()
|
||||
.CreateLogger();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test()
|
||||
{
|
||||
string dbFileName = Path.GetTempFileName() + ".sqlite3";
|
||||
|
||||
IServiceCollection services = new ServiceCollection()
|
||||
.AddLogging();
|
||||
|
||||
var connectionString = $"Data Source={dbFileName};foreign keys=true;";
|
||||
|
||||
services.AddDbContext<TvContext>(
|
||||
options => options.UseSqlite(
|
||||
connectionString,
|
||||
o =>
|
||||
{
|
||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||
}),
|
||||
ServiceLifetime.Scoped,
|
||||
ServiceLifetime.Singleton);
|
||||
|
||||
services.AddDbContextFactory<TvContext>(
|
||||
options => options.UseSqlite(
|
||||
connectionString,
|
||||
o =>
|
||||
{
|
||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||
}));
|
||||
|
||||
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
|
||||
SqlMapper.AddTypeHandler(new GuidHandler());
|
||||
SqlMapper.AddTypeHandler(new TimeSpanHandler());
|
||||
|
||||
services.AddSingleton((Func<IServiceProvider, ILoggerFactory>)(_ => new SerilogLoggerFactory()));
|
||||
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
|
||||
|
||||
ILogger<ScheduleIntegrationTests> logger = provider.GetRequiredService<ILogger<ScheduleIntegrationTests>>();
|
||||
logger.LogInformation("Database is at {File}", dbFileName);
|
||||
|
||||
await using TvContext dbContext = await factory.CreateDbContextAsync(CancellationToken.None);
|
||||
await dbContext.Database.MigrateAsync(CancellationToken.None);
|
||||
await DbInitializer.Initialize(dbContext, CancellationToken.None);
|
||||
|
||||
var path = new LibraryPath
|
||||
{
|
||||
Path = "Test LibraryPath"
|
||||
};
|
||||
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
|
||||
await dbContext.Libraries.AddAsync(library);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i < 25; i++)
|
||||
{
|
||||
var movie = new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Duration = TimeSpan.FromMinutes(55) }
|
||||
},
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = $"Movie {i}",
|
||||
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
|
||||
}
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
};
|
||||
|
||||
movies.Add(movie);
|
||||
}
|
||||
|
||||
await dbContext.Movies.AddRangeAsync(movies);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var collection = new Collection
|
||||
{
|
||||
Name = "Test Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
|
||||
await dbContext.Collections.AddAsync(collection);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
PlayoutDuration = TimeSpan.FromHours(1),
|
||||
TailMode = TailMode.None, // immediately continue
|
||||
PlaybackOrder = PlaybackOrder.Shuffle
|
||||
}
|
||||
};
|
||||
|
||||
int playoutId = await AddTestData(dbContext, scheduleItems);
|
||||
|
||||
DateTimeOffset start = new DateTimeOffset(2022, 7, 26, 8, 0, 5, TimeSpan.FromHours(-5));
|
||||
DateTimeOffset finish = start.AddDays(2);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(new Mock<ISearchIndex>().Object, factory),
|
||||
new TelevisionRepository(factory),
|
||||
new ArtistRepository(factory),
|
||||
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
|
||||
|
||||
for (var i = 0; i <= (24 * 4); i++)
|
||||
{
|
||||
await using TvContext context = await factory.CreateDbContextAsync();
|
||||
|
||||
Option<Playout> maybePlayout = await GetPlayout(context, playoutId);
|
||||
Playout playout = maybePlayout.ValueUnsafe();
|
||||
|
||||
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> AddTestData(TvContext dbContext, List<ProgramScheduleItem> scheduleItems)
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Name = "Test FFmpeg Profile"
|
||||
};
|
||||
|
||||
await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
Name = "Test Channel",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
|
||||
await dbContext.Channels.AddAsync(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Test Schedule",
|
||||
Items = scheduleItems
|
||||
};
|
||||
|
||||
await dbContext.ProgramSchedules.AddAsync(schedule);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id
|
||||
};
|
||||
|
||||
await dbContext.Playouts.AddAsync(playout);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return playout.Id;
|
||||
}
|
||||
|
||||
private static async Task<Option<Playout>> GetPlayout(TvContext dbContext, int playoutId)
|
||||
{
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.MediaItem)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PreRollFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MidRollFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PostRollFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.TailFiller)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == playoutId);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ public class ConfigElementKey
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public static ConfigElementKey MinimumLogLevel => new("log.minimum_level");
|
||||
public static ConfigElementKey FFmpegPath => new("ffmpeg.ffmpeg_path");
|
||||
public static ConfigElementKey FFprobePath => new("ffmpeg.ffprobe_path");
|
||||
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -10,21 +12,24 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
|
||||
{
|
||||
private readonly IEmbyApiClient _embyApiClient;
|
||||
private readonly IEmbyCollectionRepository _embyCollectionRepository;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger<EmbyCollectionScanner> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public EmbyCollectionScanner(
|
||||
IEmbyCollectionRepository embyCollectionRepository,
|
||||
IEmbyApiClient embyApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger<EmbyCollectionScanner> logger)
|
||||
{
|
||||
_embyCollectionRepository = embyCollectionRepository;
|
||||
_embyApiClient = embyApiClient;
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -107,7 +112,7 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
|
||||
var changedIds = removedIds.Except(addedIds).ToList();
|
||||
changedIds.AddRange(addedIds.Except(removedIds));
|
||||
|
||||
await _searchIndex.RebuildItems(_searchRepository, changedIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, changedIds);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -25,7 +26,8 @@ public class EmbyMovieLibraryScanner :
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbyMovieRepository embyMovieRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IEmbyPathReplacementService pathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -38,6 +40,7 @@ public class EmbyMovieLibraryScanner :
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_embyApiClient = embyApiClient;
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -24,7 +25,8 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbyTelevisionRepository televisionRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IEmbyPathReplacementService pathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -37,6 +39,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
localFileSystem,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
mediator,
|
||||
logger)
|
||||
{
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.6" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.2.9" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.2.9" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Environment;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
@@ -16,6 +17,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
{
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
|
||||
private readonly ILogger<FFmpegLibraryProcessService> _logger;
|
||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
@@ -25,12 +27,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
FFmpegPlaybackSettingsCalculator playbackSettingsCalculator,
|
||||
IFFmpegStreamSelector ffmpegStreamSelector,
|
||||
ITempFilePool tempFilePool,
|
||||
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
|
||||
ILogger<FFmpegLibraryProcessService> logger)
|
||||
{
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_playbackSettingsCalculator = playbackSettingsCalculator;
|
||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||
_tempFilePool = tempFilePool;
|
||||
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -214,6 +218,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
var ffmpegState = new FFmpegState(
|
||||
saveReports,
|
||||
hwAccel,
|
||||
hwAccel,
|
||||
VaapiDriverName(hwAccel, vaapiDriver),
|
||||
VaapiDeviceName(hwAccel, vaapiDevice),
|
||||
playbackSettings.StreamSeek,
|
||||
@@ -231,6 +236,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, hwAccel),
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
watermarkInputFile,
|
||||
@@ -333,6 +339,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
hwAccel,
|
||||
hwAccel,
|
||||
VaapiDriverName(hwAccel, vaapiDriver),
|
||||
VaapiDeviceName(hwAccel, vaapiDevice),
|
||||
playbackSettings.StreamSeek,
|
||||
@@ -359,6 +366,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
_logger.LogDebug("FFmpeg desired error state {FrameState}", desiredState);
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, hwAccel),
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
None,
|
||||
@@ -372,7 +380,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, pipeline);
|
||||
}
|
||||
|
||||
public Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
|
||||
public async Task<Command> ConcatChannel(
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
string scheme,
|
||||
string host)
|
||||
{
|
||||
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
|
||||
|
||||
@@ -381,6 +394,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
resolution);
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, HardwareAccelerationMode.None),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -399,13 +413,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
|
||||
_ffmpegProcessService.WrapSegmenter(ffmpegPath, saveReports, channel, scheme, host);
|
||||
|
||||
public Command ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
|
||||
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
inputFile,
|
||||
new List<VideoStream> { new(0, string.Empty, None, FrameSize.Unknown, None, true) });
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, HardwareAccelerationMode.None),
|
||||
videoInputFile,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -148,14 +148,11 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
|
||||
|
||||
audioStream.IfSome(
|
||||
stream =>
|
||||
{
|
||||
if (stream.Channels != ffmpegProfile.AudioChannels)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
});
|
||||
foreach (MediaStream _ in audioStream)
|
||||
{
|
||||
// this can be optimized out later, depending on the audio codec
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
|
||||
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
|
||||
result.AudioDuration = outPoint - inPoint;
|
||||
|
||||
@@ -46,11 +46,11 @@ public interface IFFmpegProcessService
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice);
|
||||
|
||||
Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
|
||||
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
|
||||
|
||||
Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
|
||||
|
||||
Command ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
|
||||
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
|
||||
|
||||
Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
|
||||
public interface ICachingSearchRepository : ISearchRepository
|
||||
{
|
||||
}
|
||||
@@ -22,12 +22,7 @@ public interface ITelevisionRepository
|
||||
Task<int> GetEpisodeCount(int seasonId);
|
||||
Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize);
|
||||
Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(
|
||||
int libraryPathId,
|
||||
string showFolder,
|
||||
ShowMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(int libraryPathId, ShowMetadata metadata);
|
||||
Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
|
||||
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
|
||||
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
|
||||
@@ -42,5 +37,7 @@ public interface ITelevisionRepository
|
||||
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
|
||||
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
Task<bool> UpdateTitles(EpisodeMetadata metadata, string title, string sortTitle);
|
||||
Task<bool> UpdateOutline(EpisodeMetadata metadata, string outline);
|
||||
Task<bool> UpdatePlot(EpisodeMetadata metadata, string plot);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Search;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Search;
|
||||
@@ -9,9 +10,18 @@ public interface ISearchIndex : IDisposable
|
||||
{
|
||||
public int Version { get; }
|
||||
Task<bool> Initialize(ILocalFileSystem localFileSystem, IConfigElementRepository configElementRepository);
|
||||
Task<Unit> Rebuild(ISearchRepository searchRepository);
|
||||
Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds);
|
||||
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
|
||||
Task<Unit> Rebuild(ICachingSearchRepository searchRepository, IFallbackMetadataProvider fallbackMetadataProvider);
|
||||
|
||||
Task<Unit> RebuildItems(
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
List<int> itemIds);
|
||||
|
||||
Task<Unit> UpdateItems(
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
List<MediaItem> items);
|
||||
|
||||
Task<Unit> RemoveItems(List<int> ids);
|
||||
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
|
||||
void Commit();
|
||||
|
||||
@@ -76,7 +76,8 @@ public class ChannelGuide
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(kvp => kvp.Key.Number))
|
||||
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(
|
||||
kvp => decimal.Parse(kvp.Key.Number)))
|
||||
{
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -8,23 +10,26 @@ namespace ErsatzTV.Core.Jellyfin;
|
||||
|
||||
public class JellyfinCollectionScanner : IJellyfinCollectionScanner
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinCollectionRepository _jellyfinCollectionRepository;
|
||||
private readonly ILogger<JellyfinCollectionScanner> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public JellyfinCollectionScanner(
|
||||
IJellyfinCollectionRepository jellyfinCollectionRepository,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger<JellyfinCollectionScanner> logger)
|
||||
{
|
||||
_jellyfinCollectionRepository = jellyfinCollectionRepository;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -116,7 +121,7 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
|
||||
var changedIds = removedIds.Except(addedIds).ToList();
|
||||
changedIds.AddRange(addedIds.Except(removedIds));
|
||||
|
||||
await _searchIndex.RebuildItems(_searchRepository, changedIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, changedIds);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -24,7 +25,8 @@ public class JellyfinMovieLibraryScanner :
|
||||
ISearchIndex searchIndex,
|
||||
IMediator mediator,
|
||||
IJellyfinMovieRepository jellyfinMovieRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IJellyfinPathReplacementService pathReplacementService,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -38,6 +40,7 @@ public class JellyfinMovieLibraryScanner :
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -25,7 +26,8 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinTelevisionRepository televisionRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IJellyfinPathReplacementService pathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -38,6 +40,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
|
||||
localFileSystem,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
mediator,
|
||||
logger)
|
||||
{
|
||||
|
||||
@@ -161,8 +161,14 @@ public class FallbackMetadataProvider : IFallbackMetadataProvider
|
||||
|
||||
try
|
||||
{
|
||||
const string PATTERN = @"[sS]\d+[eE]([e\-\d{1,2}]+)";
|
||||
const string PATTERN = @"[sS]\d+[\._xX]?[eE]([e\-\d{1,2}]+)";
|
||||
const string PATTERN_2 = @"\d+[\._xX]([e\-\d{1,2}]+)";
|
||||
MatchCollection matches = Regex.Matches(fileName, PATTERN);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
matches = Regex.Matches(fileName, PATTERN_2);
|
||||
}
|
||||
|
||||
if (matches.Count > 0)
|
||||
{
|
||||
foreach (Match match in matches)
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.MediaServer;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,13 +16,14 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
where TMovie : Movie
|
||||
where TEtag : MediaServerItemEtag
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
protected MediaServerMovieLibraryScanner(
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -29,7 +31,8 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
ILocalFileSystem localFileSystem,
|
||||
IMediator mediator,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger logger)
|
||||
{
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
@@ -38,6 +41,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
_mediator = mediator;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -168,7 +172,10 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +183,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
// trash movies that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
|
||||
@@ -241,7 +248,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
{
|
||||
foreach (int id in await movieRepository.FlagUnavailable(library, incoming))
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, new List<int> { id });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.MediaServer;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -18,20 +19,22 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
where TEpisode : Episode
|
||||
where TEtag : MediaServerItemEtag
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
protected MediaServerTelevisionLibraryScanner(
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalSubtitlesProvider localSubtitlesProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IMediator mediator,
|
||||
ILogger logger)
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
_localFileSystem = localFileSystem;
|
||||
_searchRepository = searchRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -196,7 +200,10 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +211,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
// trash shows that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
|
||||
@@ -358,7 +365,10 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,7 +376,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
// trash seasons that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
@@ -461,7 +471,10 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,7 +482,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
// trash episodes that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
@@ -511,7 +524,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
{
|
||||
foreach (int id in await televisionRepository.FlagUnavailable(library, incoming))
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, new List<int> { id });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,6 +16,7 @@ namespace ErsatzTV.Core.Metadata;
|
||||
public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
@@ -23,7 +25,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public MovieFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -34,7 +36,8 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IMediator mediator,
|
||||
@@ -59,6 +62,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_libraryRepository = libraryRepository;
|
||||
_mediator = mediator;
|
||||
_client = client;
|
||||
@@ -160,7 +164,10 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, movieFolder, etag);
|
||||
@@ -174,7 +181,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
{
|
||||
_logger.LogInformation("Flagging missing movie at {Path}", path);
|
||||
List<int> ids = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,6 +16,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
@@ -23,7 +25,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public MusicVideoFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -33,7 +35,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IArtistRepository artistRepository,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
@@ -58,6 +61,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
_localSubtitlesProvider = localSubtitlesProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_artistRepository = artistRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
@@ -137,7 +141,10 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +162,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
{
|
||||
_logger.LogInformation("Flagging missing music video at {Path}", path);
|
||||
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, musicVideoIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
@@ -330,7 +337,10 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -14,6 +15,7 @@ namespace ErsatzTV.Core.Metadata;
|
||||
public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScanner
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
@@ -22,7 +24,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
|
||||
public OtherVideoFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -33,7 +35,8 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
IImageCache imageCache,
|
||||
IMediator mediator,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IOtherVideoRepository otherVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
@@ -57,6 +60,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
_mediator = mediator;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_otherVideoRepository = otherVideoRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
_client = client;
|
||||
@@ -160,7 +164,10 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +185,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
{
|
||||
_logger.LogInformation("Flagging missing other video at {Path}", path);
|
||||
List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, otherVideoIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, otherVideoIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,13 +16,14 @@ namespace ErsatzTV.Core.Metadata;
|
||||
public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<SongFolderScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
private readonly ISongRepository _songRepository;
|
||||
|
||||
public SongFolderScanner(
|
||||
@@ -32,7 +34,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
IImageCache imageCache,
|
||||
IMediator mediator,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ISongRepository songRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
@@ -55,6 +58,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
_mediator = mediator;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_songRepository = songRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
_client = client;
|
||||
@@ -155,7 +159,10 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,7 +180,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
{
|
||||
_logger.LogInformation("Flagging missing song at {Path}", path);
|
||||
List<int> songIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, songIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, songIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -14,6 +15,7 @@ namespace ErsatzTV.Core.Metadata;
|
||||
public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScanner
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
@@ -22,7 +24,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ICachingSearchRepository _searchRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public TelevisionFolderScanner(
|
||||
@@ -30,11 +32,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
ITelevisionRepository televisionRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILocalSubtitlesProvider localSubtitlesProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IMediator mediator,
|
||||
@@ -55,6 +58,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
_localFileSystem = localFileSystem;
|
||||
_televisionRepository = televisionRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localSubtitlesProvider = localSubtitlesProvider;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
@@ -127,7 +131,10 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,8 +144,9 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing episode at {Path}", path);
|
||||
|
||||
List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, episodeIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
@@ -177,7 +185,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
return new MediaItemScanResult<Show>(show);
|
||||
}
|
||||
|
||||
return await _televisionRepository.AddShow(libraryPathId, showFolder, metadata);
|
||||
return await _televisionRepository.AddShow(libraryPathId, metadata);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanSeasons(
|
||||
@@ -241,7 +249,10 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
|
||||
|
||||
season.Show = show;
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { season.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { season.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +298,10 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
foreach (Episode episode in maybeEpisode.RightToSeq())
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { episode.Id });
|
||||
await _searchIndex.RebuildItems(
|
||||
_searchRepository,
|
||||
_fallbackMetadataProvider,
|
||||
new List<int> { episode.Id });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -27,7 +28,8 @@ public class PlexMovieLibraryScanner :
|
||||
IMovieRepository movieRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexMovieRepository plexMovieRepository,
|
||||
@@ -43,6 +45,7 @@ public class PlexMovieLibraryScanner :
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
fallbackMetadataProvider,
|
||||
logger)
|
||||
{
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
@@ -27,7 +28,8 @@ public class PlexTelevisionLibraryScanner :
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ICachingSearchRepository searchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
@@ -42,6 +44,7 @@ public class PlexTelevisionLibraryScanner :
|
||||
localFileSystem,
|
||||
searchRepository,
|
||||
searchIndex,
|
||||
fallbackMetadataProvider,
|
||||
mediator,
|
||||
logger)
|
||||
{
|
||||
@@ -520,6 +523,34 @@ public class PlexTelevisionLibraryScanner :
|
||||
}
|
||||
}
|
||||
|
||||
if (fullMetadata.SortTitle != existingMetadata.SortTitle || fullMetadata.Title != existingMetadata.Title)
|
||||
{
|
||||
existingMetadata.Title = fullMetadata.Title;
|
||||
existingMetadata.SortTitle = fullMetadata.SortTitle;
|
||||
if (await _televisionRepository.UpdateTitles(existingMetadata, fullMetadata.Title, fullMetadata.SortTitle))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullMetadata.Outline != existingMetadata.Outline)
|
||||
{
|
||||
existingMetadata.Outline = fullMetadata.Outline;
|
||||
if (await _televisionRepository.UpdateOutline(existingMetadata, fullMetadata.Outline))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullMetadata.Plot != existingMetadata.Plot)
|
||||
{
|
||||
existingMetadata.Plot = fullMetadata.Plot;
|
||||
if (await _televisionRepository.UpdatePlot(existingMetadata, fullMetadata.Plot))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Thumbnail))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
public enum PlayoutBuildMode
|
||||
{
|
||||
// this continues building playout into the future
|
||||
/// <summary>
|
||||
/// Continue building the playout into the future, without changing any existing playout items
|
||||
/// </summary>
|
||||
Continue = 1,
|
||||
|
||||
// this rebuilds a playout but will maintain collection progress
|
||||
/// <summary>
|
||||
/// Rebuild the playout while attempting to maintain collection progress
|
||||
/// </summary>
|
||||
Refresh = 2,
|
||||
|
||||
// this rebuilds a playout and clears all state
|
||||
/// <summary>
|
||||
/// Rebuild the playout from scratch (clearing all state)
|
||||
/// </summary>
|
||||
Reset = 3
|
||||
}
|
||||
|
||||
@@ -626,9 +626,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
&& a.MediaItemId == collectionKey.MediaItemId
|
||||
&& a.AnchorDate is null);
|
||||
|
||||
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary(
|
||||
mcs => mcs.Key,
|
||||
mcs => mcs.Head());
|
||||
var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State);
|
||||
|
||||
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match(
|
||||
existing =>
|
||||
@@ -658,10 +656,10 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
result.Add(scheduleAnchor);
|
||||
}
|
||||
|
||||
foreach (PlayoutProgramScheduleAnchor continueAnchor in playout.ProgramScheduleAnchors.Where(
|
||||
foreach (PlayoutProgramScheduleAnchor checkpointAnchor in playout.ProgramScheduleAnchors.Where(
|
||||
a => a.AnchorDate is not null))
|
||||
{
|
||||
result.Add(continueAnchor);
|
||||
result.Add(checkpointAnchor);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -675,7 +673,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
bool randomStartPoint)
|
||||
{
|
||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
|
||||
.OrderByDescending(a => a.AnchorDate is null)
|
||||
.OrderByDescending(a => a.AnchorDate ?? DateTime.MaxValue)
|
||||
.FirstOrDefault(
|
||||
a => a.ProgramScheduleId == playout.ProgramScheduleId
|
||||
&& a.CollectionType == collectionKey.CollectionType
|
||||
|
||||
@@ -676,7 +676,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
}
|
||||
else
|
||||
{
|
||||
if (itemDuration >= duration * 2)
|
||||
if (itemDuration >= duration * 1.5)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Filler item is too long {FillerDuration} to fill {GapDuration}; skipping to next filler item",
|
||||
@@ -688,6 +688,14 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
}
|
||||
else
|
||||
{
|
||||
if (itemDuration > duration)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Filler item is too long {FillerDuration} to fill {GapDuration}; aborting filler block",
|
||||
itemDuration,
|
||||
duration);
|
||||
}
|
||||
|
||||
// set to zero so it breaks out of the while loop
|
||||
remainingToFill = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Encoder;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
@@ -54,6 +55,7 @@ public class PipelineGeneratorTests
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
HardwareAccelerationMode.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
TimeSpan.FromSeconds(1),
|
||||
@@ -68,7 +70,15 @@ public class PipelineGeneratorTests
|
||||
0,
|
||||
Option<int>.None);
|
||||
|
||||
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", "", _logger);
|
||||
var builder = new PipelineBuilder(
|
||||
new DefaultHardwareCapabilities(),
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
None,
|
||||
None,
|
||||
"",
|
||||
"",
|
||||
_logger);
|
||||
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
|
||||
|
||||
result.PipelineSteps.Should().HaveCountGreaterThan(0);
|
||||
@@ -76,7 +86,77 @@ public class PipelineGeneratorTests
|
||||
|
||||
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
|
||||
command.Should().Be(
|
||||
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -re -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:a aac -ac 2 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
|
||||
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -re -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Aac_6_Channel_Should_Specify_Audio_Channels()
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<VideoStream>
|
||||
{ new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) });
|
||||
|
||||
var audioInputFile = new AudioInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<AudioStream> { new(1, AudioFormat.Aac, 6) },
|
||||
new AudioState(
|
||||
AudioFormat.Aac,
|
||||
6,
|
||||
320,
|
||||
640,
|
||||
48,
|
||||
Option<TimeSpan>.None,
|
||||
false));
|
||||
|
||||
var desiredState = new FrameState(
|
||||
true,
|
||||
false,
|
||||
VideoFormat.Hevc,
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(1920, 1080),
|
||||
new FrameSize(1920, 1080),
|
||||
Option<int>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
HardwareAccelerationMode.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
TimeSpan.FromSeconds(1),
|
||||
Option<TimeSpan>.None,
|
||||
false,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
OutputFormatKind.MpegTs,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
0,
|
||||
Option<int>.None);
|
||||
|
||||
var builder = new PipelineBuilder(
|
||||
new DefaultHardwareCapabilities(),
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
None,
|
||||
None,
|
||||
"",
|
||||
"",
|
||||
_logger);
|
||||
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
|
||||
|
||||
result.PipelineSteps.Should().HaveCountGreaterThan(0);
|
||||
result.PipelineSteps.Should().Contain(ps => ps is EncoderLibx265);
|
||||
|
||||
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
|
||||
command.Should().Be(
|
||||
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -re -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -85,7 +165,7 @@ public class PipelineGeneratorTests
|
||||
var resolution = new FrameSize(1920, 1080);
|
||||
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", resolution);
|
||||
|
||||
var builder = new PipelineBuilder(None, None, None, None, "", "", _logger);
|
||||
var builder = new PipelineBuilder(new DefaultHardwareCapabilities(), None, None, None, None, "", "", _logger);
|
||||
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
|
||||
|
||||
result.PipelineSteps.Should().HaveCountGreaterThan(0);
|
||||
@@ -132,6 +212,7 @@ public class PipelineGeneratorTests
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
HardwareAccelerationMode.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<TimeSpan>.None,
|
||||
@@ -146,7 +227,15 @@ public class PipelineGeneratorTests
|
||||
0,
|
||||
Option<int>.None);
|
||||
|
||||
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", "", _logger);
|
||||
var builder = new PipelineBuilder(
|
||||
new DefaultHardwareCapabilities(),
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
None,
|
||||
None,
|
||||
"",
|
||||
"",
|
||||
_logger);
|
||||
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
|
||||
|
||||
result.PipelineSteps.Should().HaveCountGreaterThan(0);
|
||||
@@ -172,6 +261,7 @@ public class PipelineGeneratorTests
|
||||
});
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
new DefaultHardwareCapabilities(),
|
||||
videoInputFile,
|
||||
Option<AudioInputFile>.None,
|
||||
Option<WatermarkInputFile>.None,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public class DefaultHardwareCapabilities : IHardwareCapabilities
|
||||
{
|
||||
public bool CanDecode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => true;
|
||||
public bool CanEncode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => true;
|
||||
}
|
||||
85
ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs
Normal file
85
ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
|
||||
{
|
||||
private const string ArchitectureCacheKey = "ffmpeg.hardware.nvidia.architecture";
|
||||
private const string ModelCacheKey = "ffmpeg.hardware.nvidia.model";
|
||||
private readonly ILogger<HardwareCapabilitiesFactory> _logger;
|
||||
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public HardwareCapabilitiesFactory(IMemoryCache memoryCache, ILogger<HardwareCapabilitiesFactory> logger)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IHardwareCapabilities> GetHardwareCapabilities(
|
||||
string ffmpegPath,
|
||||
HardwareAccelerationMode hardwareAccelerationMode) =>
|
||||
hardwareAccelerationMode switch
|
||||
{
|
||||
HardwareAccelerationMode.Nvenc => await GetNvidiaCapabilities(ffmpegPath),
|
||||
_ => new DefaultHardwareCapabilities()
|
||||
};
|
||||
|
||||
private async Task<IHardwareCapabilities> GetNvidiaCapabilities(string ffmpegPath)
|
||||
{
|
||||
if (_memoryCache.TryGetValue(ArchitectureCacheKey, out int cachedArchitecture)
|
||||
&& _memoryCache.TryGetValue(ModelCacheKey, out string cachedModel))
|
||||
{
|
||||
return new NvidiaHardwareCapabilities(cachedArchitecture, cachedModel);
|
||||
}
|
||||
|
||||
string[] arguments =
|
||||
{
|
||||
"-f", "lavfi",
|
||||
"-i", "nullsrc",
|
||||
"-c:v", "h264_nvenc",
|
||||
"-gpu", "list",
|
||||
"-f", "null", "-"
|
||||
};
|
||||
|
||||
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteBufferedAsync(Encoding.UTF8);
|
||||
|
||||
string output = string.IsNullOrWhiteSpace(result.StandardOutput)
|
||||
? result.StandardError
|
||||
: result.StandardOutput;
|
||||
|
||||
Option<string> maybeLine = Optional(output.Split("\n").FirstOrDefault(x => x.Contains("GPU")));
|
||||
foreach (string line in maybeLine)
|
||||
{
|
||||
const string ARCHITECTURE_PATTERN = @"SM\s+(\d\.\d)";
|
||||
Match match = Regex.Match(line, ARCHITECTURE_PATTERN);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value.Replace(".", string.Empty), out int architecture))
|
||||
{
|
||||
const string MODEL_PATTERN = @"(GTX\s+[0-9a-zA-Z]+[\sTtIi]+)";
|
||||
Match modelMatch = Regex.Match(line, MODEL_PATTERN);
|
||||
string model = modelMatch.Success ? modelMatch.Groups[1].Value.Trim() : "unknown";
|
||||
_logger.LogInformation(
|
||||
"Detected NVIDIA GPU model {Model} architecture SM {Architecture}",
|
||||
model,
|
||||
architecture);
|
||||
_memoryCache.Set(ArchitectureCacheKey, architecture);
|
||||
_memoryCache.Set(ModelCacheKey, model);
|
||||
return new NvidiaHardwareCapabilities(architecture, model);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Error detecting NVIDIA GPU capabilities; some hardware accelerated features will be unavailable: {ExitCode}",
|
||||
result.ExitCode);
|
||||
|
||||
return new NoHardwareCapabilities();
|
||||
}
|
||||
}
|
||||
9
ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilities.cs
Normal file
9
ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilities.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public interface IHardwareCapabilities
|
||||
{
|
||||
public bool CanDecode(string videoFormat, Option<IPixelFormat> maybePixelFormat);
|
||||
public bool CanEncode(string videoFormat, Option<IPixelFormat> maybePixelFormat);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public interface IHardwareCapabilitiesFactory
|
||||
{
|
||||
Task<IHardwareCapabilities> GetHardwareCapabilities(
|
||||
string ffmpegPath,
|
||||
HardwareAccelerationMode hardwareAccelerationMode);
|
||||
}
|
||||
9
ErsatzTV.FFmpeg/Capabilities/NoHardwareCapabilities.cs
Normal file
9
ErsatzTV.FFmpeg/Capabilities/NoHardwareCapabilities.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public class NoHardwareCapabilities : IHardwareCapabilities
|
||||
{
|
||||
public bool CanDecode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => false;
|
||||
public bool CanEncode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => false;
|
||||
}
|
||||
54
ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs
Normal file
54
ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public class NvidiaHardwareCapabilities : IHardwareCapabilities
|
||||
{
|
||||
private readonly int _architecture;
|
||||
private readonly List<string> _maxwellGm206 = new() { "GTX 750", "GTX 950", "GTX 960", "GTX 965M" };
|
||||
private readonly string _model;
|
||||
|
||||
public NvidiaHardwareCapabilities(int architecture, string model)
|
||||
{
|
||||
_architecture = architecture;
|
||||
_model = model;
|
||||
}
|
||||
|
||||
public bool CanDecode(string videoFormat, Option<IPixelFormat> maybePixelFormat)
|
||||
{
|
||||
int bitDepth = maybePixelFormat.Map(pf => pf.BitDepth).IfNone(8);
|
||||
|
||||
return videoFormat switch
|
||||
{
|
||||
// some second gen maxwell can decode hevc, otherwise pascal is required
|
||||
VideoFormat.Hevc => _architecture == 52 && _maxwellGm206.Contains(_model) || _architecture >= 60,
|
||||
|
||||
// pascal is required to decode vp9 10-bit
|
||||
VideoFormat.Vp9 when bitDepth == 10 => _architecture >= 60,
|
||||
|
||||
// some second gen maxwell can decode vp9, otherwise pascal is required
|
||||
VideoFormat.Vp9 => _architecture == 52 && _maxwellGm206.Contains(_model) || _architecture >= 60,
|
||||
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
public bool CanEncode(string videoFormat, Option<IPixelFormat> maybePixelFormat)
|
||||
{
|
||||
int bitDepth = maybePixelFormat.Map(pf => pf.BitDepth).IfNone(8);
|
||||
|
||||
return videoFormat switch
|
||||
{
|
||||
// pascal is required to encode 10-bit hevc
|
||||
VideoFormat.Hevc when bitDepth == 10 => _architecture >= 60,
|
||||
|
||||
// second gen maxwell is required to encode hevc
|
||||
VideoFormat.Hevc => _architecture >= 52,
|
||||
|
||||
// nvidia cannot encode 10-bit h264
|
||||
VideoFormat.H264 when bitDepth == 10 => false,
|
||||
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.FFmpeg.Decoder.Cuvid;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Decoder.Cuvid;
|
||||
using ErsatzTV.FFmpeg.Decoder.Qsv;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -8,16 +9,19 @@ namespace ErsatzTV.FFmpeg.Decoder;
|
||||
public static class AvailableDecoders
|
||||
{
|
||||
public static Option<IDecoder> ForVideoFormat(
|
||||
IHardwareCapabilities hardwareCapabilities,
|
||||
FFmpegState ffmpegState,
|
||||
FrameState currentState,
|
||||
FrameState desiredState,
|
||||
Option<WatermarkInputFile> watermarkInputFile,
|
||||
Option<SubtitleInputFile> subtitleInputFile,
|
||||
ILogger logger) =>
|
||||
(ffmpegState.HardwareAccelerationMode, currentState.VideoFormat,
|
||||
(ffmpegState.DecoderHardwareAccelerationMode, currentState.VideoFormat,
|
||||
currentState.PixelFormat.Match(pf => pf.Name, () => string.Empty)) switch
|
||||
{
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc, _) => new DecoderHevcCuvid(),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc, _)
|
||||
when hardwareCapabilities.CanDecode(VideoFormat.Hevc, currentState.PixelFormat) =>
|
||||
new DecoderHevcCuvid(ffmpegState),
|
||||
|
||||
// nvenc doesn't support hardware decoding of 10-bit content
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.H264, PixelFormat.YUV420P10LE or PixelFormat.YUV444P10LE)
|
||||
@@ -27,12 +31,17 @@ public static class AvailableDecoders
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Mpeg2Video, _) when desiredState.Deinterlaced =>
|
||||
new DecoderMpeg2Video(),
|
||||
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.H264, _) => new DecoderH264Cuvid(),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.H264, _)
|
||||
when hardwareCapabilities.CanDecode(VideoFormat.H264, currentState.PixelFormat) =>
|
||||
new DecoderH264Cuvid(ffmpegState),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Mpeg2Video, _) => new DecoderMpeg2Cuvid(
|
||||
ffmpegState,
|
||||
desiredState.Deinterlaced),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Vc1, _) => new DecoderVc1Cuvid(),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Vp9, _) => new DecoderVp9Cuvid(),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Mpeg4, _) => new DecoderMpeg4Cuvid(),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Vc1, _) => new DecoderVc1Cuvid(ffmpegState),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Vp9, _)
|
||||
when hardwareCapabilities.CanDecode(VideoFormat.Vp9, currentState.PixelFormat) =>
|
||||
new DecoderVp9Cuvid(ffmpegState),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Mpeg4, _) => new DecoderMpeg4Cuvid(ffmpegState),
|
||||
|
||||
// hevc_qsv decoder sometimes causes green lines with 10-bit content
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.Hevc, PixelFormat.YUV420P10LE) => new DecoderHevc(),
|
||||
|
||||
@@ -2,16 +2,31 @@
|
||||
|
||||
public class DecoderH264Cuvid : DecoderBase
|
||||
{
|
||||
private readonly FFmpegState _ffmpegState;
|
||||
|
||||
public DecoderH264Cuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
|
||||
|
||||
public override string Name => "h264_cuvid";
|
||||
|
||||
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
|
||||
protected override FrameDataLocation OutputFrameDataLocation =>
|
||||
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
|
||||
? FrameDataLocation.Software
|
||||
: FrameDataLocation.Hardware;
|
||||
|
||||
public override IList<string> InputOptions(InputFile inputFile)
|
||||
{
|
||||
IList<string> result = base.InputOptions(inputFile);
|
||||
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
if (_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None)
|
||||
{
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add(InputBitDepth(inputFile) == 10 ? "p010le" : "nv12");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,31 @@
|
||||
|
||||
public class DecoderHevcCuvid : DecoderBase
|
||||
{
|
||||
private readonly FFmpegState _ffmpegState;
|
||||
|
||||
public DecoderHevcCuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
|
||||
|
||||
public override string Name => "hevc_cuvid";
|
||||
|
||||
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
|
||||
protected override FrameDataLocation OutputFrameDataLocation =>
|
||||
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
|
||||
? FrameDataLocation.Software
|
||||
: FrameDataLocation.Hardware;
|
||||
|
||||
public override IList<string> InputOptions(InputFile inputFile)
|
||||
{
|
||||
IList<string> result = base.InputOptions(inputFile);
|
||||
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
if (_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None)
|
||||
{
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add(InputBitDepth(inputFile) == 10 ? "p010le" : "nv12");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -3,20 +3,35 @@
|
||||
public class DecoderMpeg2Cuvid : DecoderBase
|
||||
{
|
||||
private readonly bool _contentIsInterlaced;
|
||||
private readonly FFmpegState _ffmpegState;
|
||||
|
||||
public DecoderMpeg2Cuvid(bool contentIsInterlaced) => _contentIsInterlaced = contentIsInterlaced;
|
||||
public DecoderMpeg2Cuvid(FFmpegState ffmpegState, bool contentIsInterlaced)
|
||||
{
|
||||
_ffmpegState = ffmpegState;
|
||||
_contentIsInterlaced = contentIsInterlaced;
|
||||
}
|
||||
|
||||
public override string Name => "mpeg2_cuvid";
|
||||
|
||||
protected override FrameDataLocation OutputFrameDataLocation =>
|
||||
_contentIsInterlaced ? FrameDataLocation.Software : FrameDataLocation.Hardware;
|
||||
_contentIsInterlaced || _ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
|
||||
? FrameDataLocation.Software
|
||||
: FrameDataLocation.Hardware;
|
||||
|
||||
public override IList<string> InputOptions(InputFile inputFile)
|
||||
{
|
||||
IList<string> result = base.InputOptions(inputFile);
|
||||
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
if (_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None)
|
||||
{
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add(InputBitDepth(inputFile) == 10 ? "p010le" : "nv12");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,31 @@
|
||||
|
||||
public class DecoderMpeg4Cuvid : DecoderBase
|
||||
{
|
||||
private readonly FFmpegState _ffmpegState;
|
||||
|
||||
public DecoderMpeg4Cuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
|
||||
|
||||
public override string Name => "mpeg4_cuvid";
|
||||
|
||||
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
|
||||
protected override FrameDataLocation OutputFrameDataLocation =>
|
||||
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
|
||||
? FrameDataLocation.Software
|
||||
: FrameDataLocation.Hardware;
|
||||
|
||||
public override IList<string> InputOptions(InputFile inputFile)
|
||||
{
|
||||
IList<string> result = base.InputOptions(inputFile);
|
||||
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
if (_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None)
|
||||
{
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add(InputBitDepth(inputFile) == 10 ? "p010le" : "nv12");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
public class DecoderVc1Cuvid : DecoderBase
|
||||
{
|
||||
private readonly FFmpegState _ffmpegState;
|
||||
|
||||
public DecoderVc1Cuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
|
||||
|
||||
public override string Name => "vc1_cuvid";
|
||||
|
||||
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
|
||||
protected override FrameDataLocation OutputFrameDataLocation =>
|
||||
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
|
||||
? FrameDataLocation.Software
|
||||
: FrameDataLocation.Hardware;
|
||||
|
||||
public override IList<string> InputOptions(InputFile inputFile)
|
||||
{
|
||||
IList<string> result = base.InputOptions(inputFile);
|
||||
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
result.Add(_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None ? "cuda" : "nv12");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
public class DecoderVp9Cuvid : DecoderBase
|
||||
{
|
||||
private readonly FFmpegState _ffmpegState;
|
||||
|
||||
public DecoderVp9Cuvid(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
|
||||
|
||||
public override string Name => "vp9_cuvid";
|
||||
|
||||
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
|
||||
protected override FrameDataLocation OutputFrameDataLocation =>
|
||||
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None
|
||||
? FrameDataLocation.Software
|
||||
: FrameDataLocation.Hardware;
|
||||
|
||||
public override IList<string> InputOptions(InputFile inputFile)
|
||||
{
|
||||
IList<string> result = base.InputOptions(inputFile);
|
||||
|
||||
result.Add("-hwaccel_output_format");
|
||||
result.Add("cuda");
|
||||
result.Add(_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.None ? "cuda" : "nv12");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.FFmpeg.Environment;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Decoder;
|
||||
|
||||
@@ -20,4 +21,22 @@ public abstract class DecoderBase : IDecoder
|
||||
public bool AppliesTo(VideoInputFile videoInputFile) => true;
|
||||
|
||||
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
|
||||
|
||||
protected int InputBitDepth(InputFile inputFile)
|
||||
{
|
||||
var bitDepth = 8;
|
||||
|
||||
if (inputFile is VideoInputFile videoInputFile)
|
||||
{
|
||||
foreach (VideoStream videoStream in videoInputFile.VideoStreams.HeadOrNone())
|
||||
{
|
||||
foreach (IPixelFormat pixelFormat in videoStream.PixelFormat)
|
||||
{
|
||||
bitDepth = pixelFormat.BitDepth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bitDepth;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.FFmpeg.Encoder.Nvenc;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Encoder.Nvenc;
|
||||
using ErsatzTV.FFmpeg.Encoder.Qsv;
|
||||
using ErsatzTV.FFmpeg.Encoder.Vaapi;
|
||||
using ErsatzTV.FFmpeg.Encoder.VideoToolbox;
|
||||
@@ -11,45 +12,68 @@ namespace ErsatzTV.FFmpeg.Encoder;
|
||||
public static class AvailableEncoders
|
||||
{
|
||||
public static Option<IEncoder> ForVideoFormat(
|
||||
IHardwareCapabilities hardwareCapabilities,
|
||||
FFmpegState ffmpegState,
|
||||
FrameState currentState,
|
||||
FrameState desiredState,
|
||||
Option<WatermarkInputFile> maybeWatermarkInputFile,
|
||||
Option<SubtitleInputFile> maybeSubtitleInputFile,
|
||||
ILogger logger) =>
|
||||
(ffmpegState.HardwareAccelerationMode, desiredState.VideoFormat) switch
|
||||
(ffmpegState.EncoderHardwareAccelerationMode, desiredState.VideoFormat) switch
|
||||
{
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc) => new EncoderHevcNvenc(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.H264) => new EncoderH264Nvenc(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc) when hardwareCapabilities.CanEncode(
|
||||
VideoFormat.Hevc,
|
||||
desiredState.PixelFormat) =>
|
||||
new EncoderHevcNvenc(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
(HardwareAccelerationMode.Nvenc, VideoFormat.H264) when hardwareCapabilities.CanEncode(
|
||||
VideoFormat.H264,
|
||||
desiredState.PixelFormat) =>
|
||||
new EncoderH264Nvenc(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.Hevc) => new EncoderHevcQsv(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.H264) => new EncoderH264Qsv(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.Hevc) when hardwareCapabilities.CanEncode(
|
||||
VideoFormat.Hevc,
|
||||
desiredState.PixelFormat) =>
|
||||
new EncoderHevcQsv(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.H264) when hardwareCapabilities.CanEncode(
|
||||
VideoFormat.H264,
|
||||
desiredState.PixelFormat) =>
|
||||
new EncoderH264Qsv(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
|
||||
(HardwareAccelerationMode.Vaapi, VideoFormat.Hevc) => new EncoderHevcVaapi(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
(HardwareAccelerationMode.Vaapi, VideoFormat.H264) => new EncoderH264Vaapi(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
(HardwareAccelerationMode.Vaapi, VideoFormat.Hevc) when hardwareCapabilities.CanEncode(
|
||||
VideoFormat.Hevc,
|
||||
desiredState.PixelFormat) =>
|
||||
new EncoderHevcVaapi(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
(HardwareAccelerationMode.Vaapi, VideoFormat.H264) when hardwareCapabilities.CanEncode(
|
||||
VideoFormat.H264,
|
||||
desiredState.PixelFormat) =>
|
||||
new EncoderH264Vaapi(
|
||||
currentState,
|
||||
maybeWatermarkInputFile,
|
||||
maybeSubtitleInputFile),
|
||||
|
||||
(HardwareAccelerationMode.VideoToolbox, VideoFormat.Hevc) => new EncoderHevcVideoToolbox(),
|
||||
(HardwareAccelerationMode.VideoToolbox, VideoFormat.H264) => new EncoderH264VideoToolbox(),
|
||||
(HardwareAccelerationMode.VideoToolbox, VideoFormat.Hevc) when hardwareCapabilities.CanEncode(
|
||||
VideoFormat.Hevc,
|
||||
desiredState.PixelFormat) => new EncoderHevcVideoToolbox(),
|
||||
(HardwareAccelerationMode.VideoToolbox, VideoFormat.H264) when hardwareCapabilities.CanEncode(
|
||||
VideoFormat.H264,
|
||||
desiredState.PixelFormat) => new EncoderH264VideoToolbox(),
|
||||
|
||||
(_, VideoFormat.Hevc) => new EncoderLibx265(),
|
||||
(_, VideoFormat.Hevc) => new EncoderLibx265(currentState),
|
||||
(_, VideoFormat.H264) => new EncoderLibx264(),
|
||||
(_, VideoFormat.Mpeg2Video) => new EncoderMpeg2Video(),
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.Filter;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Encoder;
|
||||
|
||||
public class EncoderLibx265 : EncoderBase
|
||||
{
|
||||
private readonly FrameState _currentState;
|
||||
|
||||
public EncoderLibx265(FrameState currentState) => _currentState = currentState;
|
||||
|
||||
public override string Filter => new HardwareDownloadFilter(_currentState).Filter;
|
||||
|
||||
// TODO: is tag:v needed for mpegts?
|
||||
public override IList<string> OutputOptions => new List<string>
|
||||
{ "-c:v", Name, "-tag:v", "hvc1", "-x265-params", "log-level=error" };
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.2.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace ErsatzTV.FFmpeg;
|
||||
|
||||
public record FFmpegState(
|
||||
bool SaveReport,
|
||||
HardwareAccelerationMode HardwareAccelerationMode,
|
||||
HardwareAccelerationMode DecoderHardwareAccelerationMode,
|
||||
HardwareAccelerationMode EncoderHardwareAccelerationMode,
|
||||
Option<string> VaapiDriver,
|
||||
Option<string> VaapiDevice,
|
||||
Option<TimeSpan> Start,
|
||||
@@ -23,6 +24,7 @@ public record FFmpegState(
|
||||
new(
|
||||
saveReport,
|
||||
HardwareAccelerationMode.None,
|
||||
HardwareAccelerationMode.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<TimeSpan>.None,
|
||||
|
||||
@@ -148,7 +148,7 @@ public class ComplexFilter : IPipelineStep
|
||||
}
|
||||
|
||||
IPipelineFilterStep overlayFilter = AvailableWatermarkOverlayFilters.ForAcceleration(
|
||||
_ffmpegState.HardwareAccelerationMode,
|
||||
_ffmpegState.EncoderHardwareAccelerationMode,
|
||||
_currentState,
|
||||
watermarkInputFile.DesiredState,
|
||||
_resolution);
|
||||
@@ -164,16 +164,16 @@ public class ComplexFilter : IPipelineStep
|
||||
// also wait to upload if a subtitle overlay is coming
|
||||
string uploadDownloadFilter = string.Empty;
|
||||
if (_maybeSubtitleInputFile.IsNone &&
|
||||
(_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi ||
|
||||
_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
|
||||
(_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Vaapi ||
|
||||
_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
|
||||
_currentState.VideoFormat == VideoFormat.Hevc))
|
||||
{
|
||||
uploadDownloadFilter = new HardwareUploadFilter(_ffmpegState).Filter;
|
||||
}
|
||||
|
||||
if (_maybeSubtitleInputFile.Map(s => !s.IsImageBased).IfNone(false) &&
|
||||
_ffmpegState.HardwareAccelerationMode != HardwareAccelerationMode.Vaapi &&
|
||||
_ffmpegState.HardwareAccelerationMode != HardwareAccelerationMode.VideoToolbox)
|
||||
_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.Vaapi &&
|
||||
_ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.VideoToolbox)
|
||||
{
|
||||
uploadDownloadFilter = new HardwareDownloadFilter(_currentState).Filter;
|
||||
}
|
||||
@@ -217,7 +217,7 @@ public class ComplexFilter : IPipelineStep
|
||||
{
|
||||
IPipelineFilterStep overlayFilter =
|
||||
AvailableSubtitleOverlayFilters.ForAcceleration(
|
||||
_ffmpegState.HardwareAccelerationMode,
|
||||
_ffmpegState.EncoderHardwareAccelerationMode,
|
||||
_currentState);
|
||||
filter = overlayFilter.Filter;
|
||||
}
|
||||
@@ -238,8 +238,8 @@ public class ComplexFilter : IPipelineStep
|
||||
// vaapi uses software overlay and needs to upload
|
||||
// videotoolbox seems to require a hwupload for hevc
|
||||
string uploadFilter = string.Empty;
|
||||
if (_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi
|
||||
|| _ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
|
||||
if (_ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Vaapi
|
||||
|| _ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
|
||||
_currentState.VideoFormat == VideoFormat.Hevc)
|
||||
{
|
||||
uploadFilter = new HardwareUploadFilter(_ffmpegState).Filter;
|
||||
|
||||
@@ -6,7 +6,7 @@ public class HardwareUploadFilter : BaseFilter
|
||||
|
||||
public HardwareUploadFilter(FFmpegState ffmpegState) => _ffmpegState = ffmpegState;
|
||||
|
||||
public override string Filter => _ffmpegState.HardwareAccelerationMode switch
|
||||
public override string Filter => _ffmpegState.EncoderHardwareAccelerationMode switch
|
||||
{
|
||||
HardwareAccelerationMode.None => string.Empty,
|
||||
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
|
||||
@@ -15,7 +15,7 @@ public class HardwareUploadFilter : BaseFilter
|
||||
_ => "hwupload"
|
||||
};
|
||||
|
||||
public override FrameState NextState(FrameState currentState) => _ffmpegState.HardwareAccelerationMode switch
|
||||
public override FrameState NextState(FrameState currentState) => _ffmpegState.EncoderHardwareAccelerationMode switch
|
||||
{
|
||||
HardwareAccelerationMode.None => currentState,
|
||||
_ => currentState with { FrameDataLocation = FrameDataLocation.Hardware }
|
||||
|
||||
@@ -12,7 +12,7 @@ public class SubtitleHardwareUploadFilter : BaseFilter
|
||||
}
|
||||
|
||||
public override string Filter =>
|
||||
_ffmpegState.HardwareAccelerationMode switch
|
||||
_ffmpegState.EncoderHardwareAccelerationMode switch
|
||||
{
|
||||
HardwareAccelerationMode.None => string.Empty,
|
||||
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
|
||||
|
||||
@@ -10,7 +10,7 @@ public class SubtitlePixelFormatFilter : BaseFilter
|
||||
{
|
||||
get
|
||||
{
|
||||
Option<string> maybeFormat = _ffmpegState.HardwareAccelerationMode switch
|
||||
Option<string> maybeFormat = _ffmpegState.EncoderHardwareAccelerationMode switch
|
||||
{
|
||||
HardwareAccelerationMode.Nvenc => "yuva420p",
|
||||
HardwareAccelerationMode.Qsv => "yuva420p",
|
||||
|
||||
@@ -11,7 +11,7 @@ public class WatermarkHardwareUploadFilter : BaseFilter
|
||||
_ffmpegState = ffmpegState;
|
||||
}
|
||||
|
||||
public override string Filter => _ffmpegState.HardwareAccelerationMode switch
|
||||
public override string Filter => _ffmpegState.EncoderHardwareAccelerationMode switch
|
||||
{
|
||||
HardwareAccelerationMode.None => string.Empty,
|
||||
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
|
||||
|
||||
@@ -19,7 +19,7 @@ public class WatermarkPixelFormatFilter : BaseFilter
|
||||
{
|
||||
bool hasFadePoints = _watermarkState.MaybeFadePoints.Map(fp => fp.Count).IfNone(0) > 0;
|
||||
|
||||
Option<string> maybeFormat = _ffmpegState.HardwareAccelerationMode switch
|
||||
Option<string> maybeFormat = _ffmpegState.EncoderHardwareAccelerationMode switch
|
||||
{
|
||||
HardwareAccelerationMode.Nvenc => "yuva420p",
|
||||
HardwareAccelerationMode.Qsv => "yuva420p",
|
||||
|
||||
@@ -4,4 +4,5 @@ public interface IPixelFormat
|
||||
{
|
||||
string Name { get; }
|
||||
string FFmpegName { get; }
|
||||
int BitDepth { get; }
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ public class PixelFormatNv12 : IPixelFormat
|
||||
public string Name { get; }
|
||||
|
||||
public string FFmpegName => "nv12";
|
||||
public int BitDepth => 8;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ public class PixelFormatUnknown : IPixelFormat
|
||||
{
|
||||
public string Name => "unknown";
|
||||
public string FFmpegName => "unknown";
|
||||
public int BitDepth => 8;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ public class PixelFormatYuv420P : IPixelFormat
|
||||
{
|
||||
public string Name => PixelFormat.YUV420P;
|
||||
public string FFmpegName => FFmpegFormat.YUV420P;
|
||||
public int BitDepth => 8;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ public class PixelFormatYuv420P10Le : IPixelFormat
|
||||
{
|
||||
public string Name => PixelFormat.YUV420P10LE;
|
||||
public string FFmpegName => FFmpegFormat.P010LE;
|
||||
public int BitDepth => 10;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ public class PixelFormatYuv444P : IPixelFormat
|
||||
{
|
||||
public string Name => PixelFormat.YUV444P;
|
||||
public string FFmpegName => FFmpegFormat.YUV444P;
|
||||
public int BitDepth => 8;
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public class PixelFormatYuvJ420P : IPixelFormat
|
||||
|
||||
// always convert this to yuv420p in filter chains
|
||||
public string FFmpegName => FFmpegFormat.YUV420P;
|
||||
public int BitDepth => 8;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
namespace ErsatzTV.FFmpeg.Option;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Option;
|
||||
|
||||
public class AudioChannelsOutputOption : OutputOption
|
||||
{
|
||||
private readonly int _channels;
|
||||
private readonly Option<string> _audioFormat;
|
||||
private readonly int _desiredChannels;
|
||||
private readonly int _sourceChannels;
|
||||
|
||||
public AudioChannelsOutputOption(int channels) => _channels = channels;
|
||||
public AudioChannelsOutputOption(Option<string> audioFormat, int sourceChannels, int desiredChannels)
|
||||
{
|
||||
_audioFormat = audioFormat;
|
||||
_sourceChannels = sourceChannels;
|
||||
_desiredChannels = desiredChannels;
|
||||
}
|
||||
|
||||
public override IList<string> OutputOptions => new List<string> { "-ac", _channels.ToString() };
|
||||
public override IList<string> OutputOptions
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_sourceChannels != _desiredChannels || _audioFormat == Some(AudioFormat.Aac) && _desiredChannels > 2)
|
||||
{
|
||||
return new List<string>
|
||||
{
|
||||
"-ac", _desiredChannels.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.FFmpeg.Decoder;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Decoder;
|
||||
using ErsatzTV.FFmpeg.Encoder;
|
||||
using ErsatzTV.FFmpeg.Environment;
|
||||
using ErsatzTV.FFmpeg.Filter;
|
||||
@@ -18,6 +19,7 @@ public class PipelineBuilder
|
||||
{
|
||||
private readonly Option<AudioInputFile> _audioInputFile;
|
||||
private readonly string _fontsFolder;
|
||||
private readonly IHardwareCapabilities _hardwareCapabilities;
|
||||
private readonly ILogger _logger;
|
||||
private readonly List<IPipelineStep> _pipelineSteps;
|
||||
private readonly string _reportsFolder;
|
||||
@@ -26,6 +28,7 @@ public class PipelineBuilder
|
||||
private readonly Option<WatermarkInputFile> _watermarkInputFile;
|
||||
|
||||
public PipelineBuilder(
|
||||
IHardwareCapabilities hardwareCapabilities,
|
||||
Option<VideoInputFile> videoInputFile,
|
||||
Option<AudioInputFile> audioInputFile,
|
||||
Option<WatermarkInputFile> watermarkInputFile,
|
||||
@@ -46,6 +49,7 @@ public class PipelineBuilder
|
||||
new ClosedGopOutputOption()
|
||||
};
|
||||
|
||||
_hardwareCapabilities = hardwareCapabilities;
|
||||
_videoInputFile = videoInputFile;
|
||||
_audioInputFile = audioInputFile;
|
||||
_watermarkInputFile = watermarkInputFile;
|
||||
@@ -200,7 +204,7 @@ public class PipelineBuilder
|
||||
else
|
||||
{
|
||||
Option<IPipelineStep> maybeAccel = AvailableHardwareAccelerationOptions.ForMode(
|
||||
ffmpegState.HardwareAccelerationMode,
|
||||
ffmpegState.EncoderHardwareAccelerationMode,
|
||||
ffmpegState.VaapiDevice,
|
||||
_logger);
|
||||
|
||||
@@ -209,24 +213,47 @@ public class PipelineBuilder
|
||||
ffmpegState = ffmpegState with
|
||||
{
|
||||
// disable hw accel if we don't match anything
|
||||
HardwareAccelerationMode = HardwareAccelerationMode.None
|
||||
DecoderHardwareAccelerationMode = HardwareAccelerationMode.None,
|
||||
EncoderHardwareAccelerationMode = HardwareAccelerationMode.None
|
||||
};
|
||||
}
|
||||
|
||||
foreach (IPipelineStep accel in maybeAccel)
|
||||
{
|
||||
currentState = accel.NextState(currentState);
|
||||
_pipelineSteps.Add(accel);
|
||||
bool canDecode = _hardwareCapabilities.CanDecode(currentState.VideoFormat, videoStream.PixelFormat);
|
||||
bool canEncode = _hardwareCapabilities.CanEncode(
|
||||
desiredState.VideoFormat,
|
||||
desiredState.PixelFormat);
|
||||
|
||||
// disable hw accel if decoder/encoder isn't supported
|
||||
if (!canDecode || !canEncode)
|
||||
{
|
||||
ffmpegState = ffmpegState with
|
||||
{
|
||||
DecoderHardwareAccelerationMode = canDecode
|
||||
? ffmpegState.DecoderHardwareAccelerationMode
|
||||
: HardwareAccelerationMode.None,
|
||||
EncoderHardwareAccelerationMode = canEncode
|
||||
? ffmpegState.EncoderHardwareAccelerationMode
|
||||
: HardwareAccelerationMode.None
|
||||
};
|
||||
}
|
||||
|
||||
if (canDecode || canEncode)
|
||||
{
|
||||
currentState = accel.NextState(currentState);
|
||||
_pipelineSteps.Add(accel);
|
||||
}
|
||||
}
|
||||
|
||||
// nvenc requires yuv420p background with yuva420p overlay
|
||||
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Nvenc && hasOverlay)
|
||||
if (ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Nvenc && hasOverlay)
|
||||
{
|
||||
desiredState = desiredState with { PixelFormat = new PixelFormatYuv420P() };
|
||||
}
|
||||
|
||||
// qsv should stay nv12
|
||||
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Qsv && hasOverlay)
|
||||
if (ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Qsv && hasOverlay)
|
||||
{
|
||||
IPixelFormat pixelFormat = desiredState.PixelFormat.IfNone(new PixelFormatYuv420P());
|
||||
desiredState = desiredState with { PixelFormat = new PixelFormatNv12(pixelFormat.Name) };
|
||||
@@ -240,6 +267,7 @@ public class PipelineBuilder
|
||||
}
|
||||
|
||||
foreach (IDecoder decoder in AvailableDecoders.ForVideoFormat(
|
||||
_hardwareCapabilities,
|
||||
ffmpegState,
|
||||
currentState,
|
||||
desiredState,
|
||||
@@ -262,7 +290,7 @@ public class PipelineBuilder
|
||||
|
||||
if (videoStream.StillImage)
|
||||
{
|
||||
var option = new InfiniteLoopInputOption(ffmpegState.HardwareAccelerationMode);
|
||||
var option = new InfiniteLoopInputOption(ffmpegState.EncoderHardwareAccelerationMode);
|
||||
_videoInputFile.Iter(f => f.AddOption(option));
|
||||
}
|
||||
|
||||
@@ -277,7 +305,7 @@ public class PipelineBuilder
|
||||
|
||||
if (desiredState.InfiniteLoop)
|
||||
{
|
||||
var option = new InfiniteLoopInputOption(ffmpegState.HardwareAccelerationMode);
|
||||
var option = new InfiniteLoopInputOption(ffmpegState.EncoderHardwareAccelerationMode);
|
||||
_audioInputFile.Iter(f => f.AddOption(option));
|
||||
_videoInputFile.Iter(f => f.AddOption(option));
|
||||
}
|
||||
@@ -325,7 +353,7 @@ public class PipelineBuilder
|
||||
if (desiredState.Deinterlaced && !currentState.Deinterlaced)
|
||||
{
|
||||
IPipelineFilterStep step = AvailableDeinterlaceFilters.ForAcceleration(
|
||||
ffmpegState.HardwareAccelerationMode,
|
||||
ffmpegState.EncoderHardwareAccelerationMode,
|
||||
currentState,
|
||||
desiredState,
|
||||
_watermarkInputFile,
|
||||
@@ -335,7 +363,7 @@ public class PipelineBuilder
|
||||
}
|
||||
|
||||
// TODO: this is a software-only flow, will need to be different for hardware accel
|
||||
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.None)
|
||||
if (ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.None)
|
||||
{
|
||||
if (currentState.ScaledSize != desiredState.ScaledSize ||
|
||||
currentState.PaddedSize != desiredState.PaddedSize)
|
||||
@@ -360,7 +388,7 @@ public class PipelineBuilder
|
||||
else if (currentState.ScaledSize != desiredState.ScaledSize)
|
||||
{
|
||||
IPipelineFilterStep scaleFilter = AvailableScaleFilters.ForAcceleration(
|
||||
ffmpegState.HardwareAccelerationMode,
|
||||
ffmpegState.EncoderHardwareAccelerationMode,
|
||||
currentState,
|
||||
desiredState.ScaledSize,
|
||||
desiredState.PaddedSize);
|
||||
@@ -382,7 +410,7 @@ public class PipelineBuilder
|
||||
else if (currentState.PaddedSize != desiredState.PaddedSize)
|
||||
{
|
||||
IPipelineFilterStep scaleFilter = AvailableScaleFilters.ForAcceleration(
|
||||
ffmpegState.HardwareAccelerationMode,
|
||||
ffmpegState.EncoderHardwareAccelerationMode,
|
||||
currentState,
|
||||
desiredState.ScaledSize,
|
||||
desiredState.PaddedSize);
|
||||
@@ -415,7 +443,7 @@ public class PipelineBuilder
|
||||
currentState = formatFilter.NextState(currentState);
|
||||
_videoInputFile.Iter(f => f.FilterSteps.Add(formatFilter));
|
||||
|
||||
switch (ffmpegState.HardwareAccelerationMode)
|
||||
switch (ffmpegState.EncoderHardwareAccelerationMode)
|
||||
{
|
||||
case HardwareAccelerationMode.Nvenc:
|
||||
var uploadFilter = new HardwareUploadFilter(ffmpegState);
|
||||
@@ -426,13 +454,13 @@ public class PipelineBuilder
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ffmpegState.HardwareAccelerationMode != HardwareAccelerationMode.Qsv)
|
||||
if (ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.Qsv)
|
||||
{
|
||||
// the filter re-applies the current pixel format, so we have to set it first
|
||||
currentState = currentState with { PixelFormat = desiredState.PixelFormat };
|
||||
|
||||
IPipelineFilterStep scaleFilter = AvailableScaleFilters.ForAcceleration(
|
||||
ffmpegState.HardwareAccelerationMode,
|
||||
ffmpegState.EncoderHardwareAccelerationMode,
|
||||
currentState,
|
||||
desiredState.ScaledSize,
|
||||
desiredState.PaddedSize);
|
||||
@@ -444,7 +472,7 @@ public class PipelineBuilder
|
||||
}
|
||||
|
||||
// nvenc custom logic
|
||||
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Nvenc)
|
||||
if (ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Nvenc)
|
||||
{
|
||||
foreach (VideoInputFile videoInputFile in _videoInputFile)
|
||||
{
|
||||
@@ -461,7 +489,7 @@ public class PipelineBuilder
|
||||
currentState = currentState with { PixelFormat = desiredState.PixelFormat };
|
||||
|
||||
IPipelineFilterStep scaleFilter = AvailableScaleFilters.ForAcceleration(
|
||||
ffmpegState.HardwareAccelerationMode,
|
||||
ffmpegState.EncoderHardwareAccelerationMode,
|
||||
currentState,
|
||||
desiredState.ScaledSize,
|
||||
desiredState.PaddedSize);
|
||||
@@ -488,7 +516,7 @@ public class PipelineBuilder
|
||||
if (currentState.PixelFormat.Map(pf => pf.FFmpegName) != desiredPixelFormat.FFmpegName)
|
||||
{
|
||||
// qsv doesn't seem to like this
|
||||
if (ffmpegState.HardwareAccelerationMode != HardwareAccelerationMode.Qsv)
|
||||
if (ffmpegState.EncoderHardwareAccelerationMode != HardwareAccelerationMode.Qsv)
|
||||
{
|
||||
IPipelineStep step = new PixelFormatOutputOption(desiredPixelFormat);
|
||||
currentState = step.NextState(currentState);
|
||||
@@ -510,9 +538,16 @@ public class PipelineBuilder
|
||||
_pipelineSteps.Add(step);
|
||||
}
|
||||
|
||||
foreach (int desiredAudioChannels in audioInputFile.DesiredState.AudioChannels)
|
||||
foreach (AudioStream audioStream in audioInputFile.AudioStreams.HeadOrNone())
|
||||
{
|
||||
_pipelineSteps.Add(new AudioChannelsOutputOption(desiredAudioChannels));
|
||||
foreach (int desiredAudioChannels in audioInputFile.DesiredState.AudioChannels)
|
||||
{
|
||||
_pipelineSteps.Add(
|
||||
new AudioChannelsOutputOption(
|
||||
audioInputFile.DesiredState.AudioFormat,
|
||||
audioStream.Channels,
|
||||
desiredAudioChannels));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int desiredBitrate in audioInputFile.DesiredState.AudioBitrate)
|
||||
@@ -547,7 +582,7 @@ public class PipelineBuilder
|
||||
{
|
||||
// vaapi and videotoolbox use a software overlay, so we need to ensure the background is already in software
|
||||
// though videotoolbox uses software decoders, so no need to download for that
|
||||
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
|
||||
if (ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
|
||||
{
|
||||
var downloadFilter = new HardwareDownloadFilter(currentState);
|
||||
currentState = downloadFilter.NextState(currentState);
|
||||
@@ -565,12 +600,12 @@ public class PipelineBuilder
|
||||
// text-based subtitles are always added in software, so always try to download the background
|
||||
|
||||
// nvidia needs some extra format help if the only filter will be the download filter
|
||||
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Nvenc &&
|
||||
if (ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Nvenc &&
|
||||
currentState.FrameDataLocation == FrameDataLocation.Hardware &&
|
||||
_videoInputFile.Map(f => f.FilterSteps.Count).IfNone(1) == 0)
|
||||
{
|
||||
IPipelineFilterStep scaleFilter = AvailableScaleFilters.ForAcceleration(
|
||||
ffmpegState.HardwareAccelerationMode,
|
||||
ffmpegState.EncoderHardwareAccelerationMode,
|
||||
currentState,
|
||||
desiredState.ScaledSize,
|
||||
desiredState.PaddedSize);
|
||||
@@ -588,7 +623,7 @@ public class PipelineBuilder
|
||||
{
|
||||
// vaapi and videotoolbox use a software overlay, so we need to ensure the background is already in software
|
||||
// though videotoolbox uses software decoders, so no need to download for that
|
||||
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
|
||||
if (ffmpegState.EncoderHardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
|
||||
{
|
||||
var downloadFilter = new HardwareDownloadFilter(currentState);
|
||||
currentState = downloadFilter.NextState(currentState);
|
||||
@@ -607,7 +642,8 @@ public class PipelineBuilder
|
||||
else if (watermarkInputFile.DesiredState.MaybeFadePoints.Map(fp => fp.Count > 0).IfNone(false))
|
||||
{
|
||||
// looping is required to fade a static image in and out
|
||||
watermarkInputFile.AddOption(new InfiniteLoopInputOption(ffmpegState.HardwareAccelerationMode));
|
||||
watermarkInputFile.AddOption(
|
||||
new InfiniteLoopInputOption(ffmpegState.EncoderHardwareAccelerationMode));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,6 +670,7 @@ public class PipelineBuilder
|
||||
if (_pipelineSteps.OfType<IEncoder>().All(e => e.Kind != StreamKind.Video))
|
||||
{
|
||||
foreach (IEncoder e in AvailableEncoders.ForVideoFormat(
|
||||
_hardwareCapabilities,
|
||||
ffmpegState,
|
||||
currentState,
|
||||
desiredState,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories.Caching;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Data.Repositories.Caching;
|
||||
|
||||
[TestFixture]
|
||||
public class CachingSearchRepositoryTests
|
||||
{
|
||||
[Test]
|
||||
public async Task GetAllLanguageCodes_Should_Cache_Languages_Separately()
|
||||
{
|
||||
var englishMediaCodes = new List<string> { "eng" };
|
||||
var frenchMediaCodes = new List<string> { "fre" };
|
||||
var englishResult = new List<string> { "english_result" };
|
||||
var frenchResult = new List<string> { "french_result" };
|
||||
|
||||
var searchRepo = new Mock<ISearchRepository>();
|
||||
searchRepo.Setup(x => x.GetAllLanguageCodes(englishMediaCodes)).Returns(englishResult.AsTask());
|
||||
searchRepo.Setup(x => x.GetAllLanguageCodes(frenchMediaCodes)).Returns(frenchResult.AsTask());
|
||||
|
||||
var repo = new CachingSearchRepository(searchRepo.Object);
|
||||
|
||||
List<string> result1 = await repo.GetAllLanguageCodes(englishMediaCodes);
|
||||
result1.Should().BeEquivalentTo(englishResult);
|
||||
|
||||
List<string> result2 = await repo.GetAllLanguageCodes(frenchMediaCodes);
|
||||
result2.Should().BeEquivalentTo(frenchResult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.7.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
|
||||
<PackageReference Include="Moq" Version="4.18.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="3.3.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -44,7 +44,7 @@ public static class DbInitializer
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (context.Resolutions.Any())
|
||||
if (context.Resolutions.Any(x => x.Width == 1920))
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories.Caching;
|
||||
|
||||
public class CachingSearchRepository : ICachingSearchRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<List<string>, List<string>> _cache = new();
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly SemaphoreSlim _slim = new(1, 1);
|
||||
|
||||
public CachingSearchRepository(ISearchRepository searchRepository) => _searchRepository = searchRepository;
|
||||
|
||||
public Task<Option<MediaItem>> GetItemToIndex(int id) => _searchRepository.GetItemToIndex(id);
|
||||
|
||||
public Task<List<string>> GetLanguagesForShow(Show show) => _searchRepository.GetLanguagesForShow(show);
|
||||
|
||||
public Task<List<string>> GetLanguagesForSeason(Season season) => _searchRepository.GetLanguagesForSeason(season);
|
||||
|
||||
public Task<List<string>> GetLanguagesForArtist(Artist artist) => _searchRepository.GetLanguagesForArtist(artist);
|
||||
|
||||
public async Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes)
|
||||
{
|
||||
if (!_cache.ContainsKey(mediaCodes))
|
||||
{
|
||||
await _slim.WaitAsync();
|
||||
try
|
||||
{
|
||||
_cache.TryAdd(mediaCodes, await _searchRepository.GetAllLanguageCodes(mediaCodes));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_slim.Release();
|
||||
}
|
||||
}
|
||||
|
||||
return _cache[mediaCodes];
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<MediaItem> GetAllMediaItems() => _searchRepository.GetAllMediaItems();
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public class ConfigElementRepository : IConfigElementRepository
|
||||
|
||||
public async Task<Unit> Upsert<T>(ConfigElementKey configElementKey, T value)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
Option<ConfigElement> maybeElement = await dbContext.ConfigElements
|
||||
.SelectOneAsync(c => c.Key, c => c.Key == configElementKey.Key);
|
||||
@@ -42,7 +42,7 @@ public class ConfigElementRepository : IConfigElementRepository
|
||||
|
||||
public async Task<Option<ConfigElement>> Get(ConfigElementKey key)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.ConfigElements
|
||||
.OrderBy(ce => ce.Key)
|
||||
.SingleOrDefaultAsync(ce => ce.Key == key.Key)
|
||||
@@ -50,18 +50,27 @@ public class ConfigElementRepository : IConfigElementRepository
|
||||
}
|
||||
|
||||
public Task<Option<T>> GetValue<T>(ConfigElementKey key) =>
|
||||
Get(key).MapT(ce => (T)Convert.ChangeType(ce.Value, typeof(T)));
|
||||
Get(key).MapT(
|
||||
ce =>
|
||||
{
|
||||
if (typeof(T).IsEnum)
|
||||
{
|
||||
return (T)Enum.Parse(typeof(T), ce.Value);
|
||||
}
|
||||
|
||||
return (T)Convert.ChangeType(ce.Value, typeof(T));
|
||||
});
|
||||
|
||||
public async Task Delete(ConfigElement configElement)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
dbContext.ConfigElements.Remove(configElement);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<Unit> Delete(ConfigElementKey configElementKey)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<ConfigElement> maybeExisting = await dbContext.ConfigElements
|
||||
.SelectOneAsync(ce => ce.Key, ce => ce.Key == configElementKey.Key);
|
||||
foreach (ConfigElement element in maybeExisting)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user