Compare commits

...

37 Commits

Author SHA1 Message Date
Jason Dove
f0f2b3da4b update changelog for release v0.6.5-beta [no ci] 2022-08-02 07:36:45 -05:00
Jason Dove
866049543c fix db initializer (#907) 2022-07-31 12:30:33 -05:00
Jason Dove
40ed4b8b0e update changelog for release v0.6.4-beta [no ci] 2022-07-28 12:23:33 -05:00
Jason Dove
b43d08ca67 fix repeating schedules (#901) 2022-07-26 13:04:05 -05:00
Jason Dove
5e7e386108 fix search result filtering for episodes and other videos (#900) 2022-07-25 20:02:07 -05:00
Jason Dove
4176df9940 fix nvidia capabilities for second-gen maxwell (#899) 2022-07-24 12:23:30 -05:00
Jason Dove
de2ef959fe add 640x480 resolution (#898)
* update dependencies

* add 640x480 resolution
2022-07-24 08:17:27 -05:00
Jason Dove
b53cfebac1 fix bug with unsupported aac channel layouts (#893)
* fix bug with unsupported aac channel layouts

* update dependencies
2022-07-14 10:52:25 -05:00
Jason Dove
6895b9cc6b fix search repo caching bug (#886)
* add failing test

* fix search repo bug

* update dependencies
2022-07-10 15:32:06 -05:00
Jason Dove
c60d6e46f1 fix changelog [no ci] 2022-07-04 15:23:26 -05:00
Jason Dove
c66d190174 update changelog for release v0.6.3-beta [no ci] 2022-07-04 15:20:53 -05:00
Jason Dove
5e8da591be update dependencies (#883)
* fix database initialization

* update dependencies
2022-07-02 20:42:07 -05:00
Jason Dove
9c02a6738b fix missing trashed episodes (#881)
* fix episodes missing from trash

* cleanup
2022-06-29 15:01:49 -05:00
Jason Dove
5ed0184bca add minimum log level setting (#877) 2022-06-27 10:29:04 -05:00
Jason Dove
ae64ca4a93 fix arm images by using ls55 (#876) 2022-06-26 17:41:39 -05:00
Jason Dove
c47099895e include item state in search index duplicate filter (#875) 2022-06-26 13:34:54 -05:00
Jason Dove
a2529febba use brew for gon 2022-06-26 08:30:32 -05:00
Jason Dove
521e0ba8b3 get a new build 2022-06-26 06:46:30 -05:00
Jason Dove
ee0efac9be only publish docs when docs are updated 2022-06-26 06:21:27 -05:00
Jason Dove
bfe7635489 work around github actions issue on mac (#874) 2022-06-25 19:30:21 -05:00
Jason Dove
aa1735f024 fix song and other video search index (#873) 2022-06-25 18:13:39 -05:00
Jason Dove
8deae983c7 add some startup log messages (#872) 2022-06-25 13:03:37 -05:00
Jason Dove
f349646703 apply plex episode metadata updates (#871)
* update more plex episode metadata

* update dependencies
2022-06-22 19:41:05 -05:00
Jason Dove
5003e80500 maintain stream continuity after playout reset (#868)
* maintain stream continuity after playout reset

* maintain continuity after error streams
2022-06-18 21:38:25 -05:00
Jason Dove
940d9cd6b5 update changelog for release v0.6.2-beta [no ci] 2022-06-18 13:46:45 -05:00
Jason Dove
197c166789 fix jellyfin admin id selection (#867) 2022-06-17 18:25:22 -05:00
Jason Dove
d114db091e use proper nvidia accel output format for 10-bit content (#865) 2022-06-17 11:33:10 -05:00
Jason Dove
3204da8e43 adjust nvidia capabilities (#864)
* adjust nvidia capabilities logic

* fallback to software encoding for 10-bit h264

* cleanup

* more tweaks
2022-06-17 10:50:36 -05:00
Jason Dove
100eb14408 fix epg sorting (#863)
* fix epg sorting

* update dependencies
2022-06-17 08:44:26 -05:00
Jason Dove
025017ace5 regularly delete old segments (#856)
* regularly delete old segments

* cleanup
2022-06-15 21:12:07 -05:00
Jason Dove
6a690c7c10 add more filler logging (#854) 2022-06-15 10:21:05 -05:00
Jason Dove
dd7f77751c detect nvidia capabilities (#853)
* fallback to software codecs for old nvidia cards

* update dependencies
2022-06-14 19:44:34 -05:00
Jason Dove
0c13b8ef1a force amd64 for arm32v7 sdk build layer (#843) 2022-06-12 13:54:55 -05:00
Jason Dove
c6ca58ab97 build arm32v7 docker image (#842)
* build arm32v7 docker image

* fix
2022-06-12 13:42:27 -05:00
Jason Dove
0846fc1d96 update workflow dependencies (#841) 2022-06-11 13:40:47 -05:00
Jason Dove
e41dd68ee0 fix automatic playout building (#840) 2022-06-11 13:11:04 -05:00
Jason Dove
0a92996da8 fix repeating content (#838)
* fix repeating content

* update dependencies
2022-06-08 10:37:56 -05:00
120 changed files with 6265 additions and 425 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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' }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateGeneralSettings(GeneralSettingsViewModel GeneralSettings) : IRequest<Either<BaseError, Unit>>;

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
using Serilog.Events;
namespace ErsatzTV.Application.Configuration;
public class GeneralSettingsViewModel
{
public LogEventLevel MinimumLogLevel { get; set; }
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetGeneralSettings : IRequest<GeneralSettingsViewModel>;

View File

@@ -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)
};
}
}

View File

@@ -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" />

View File

@@ -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,

View File

@@ -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 });
}
}
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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>();

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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()
{

View File

@@ -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(

View File

@@ -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,

View File

@@ -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()
{

View 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);
}
}

View File

@@ -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");

View File

@@ -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)

View File

@@ -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;

View File

@@ -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)
{

View File

@@ -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" />

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);

View File

@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Interfaces.Repositories.Caching;
public interface ICachingSearchRepository : ISearchRepository
{
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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 &&

View File

@@ -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)

View File

@@ -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;

View File

@@ -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)
{

View File

@@ -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)

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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("._"))
{

View File

@@ -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 });
}
}
}

View File

@@ -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("._"))
{

View File

@@ -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("._"))
{

View File

@@ -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 });
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;
}

View 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();
}
}

View 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);
}

View File

@@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Capabilities;
public interface IHardwareCapabilitiesFactory
{
Task<IHardwareCapabilities> GetHardwareCapabilities(
string ffmpegPath,
HardwareAccelerationMode hardwareAccelerationMode);
}

View 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;
}

View 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
};
}
}

View File

@@ -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(),

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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(),

View File

@@ -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" };

View File

@@ -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>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 }

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -4,4 +4,5 @@ public interface IPixelFormat
{
string Name { get; }
string FFmpegName { get; }
int BitDepth { get; }
}

View File

@@ -7,4 +7,5 @@ public class PixelFormatNv12 : IPixelFormat
public string Name { get; }
public string FFmpegName => "nv12";
public int BitDepth => 8;
}

View File

@@ -4,4 +4,5 @@ public class PixelFormatUnknown : IPixelFormat
{
public string Name => "unknown";
public string FFmpegName => "unknown";
public int BitDepth => 8;
}

View File

@@ -4,4 +4,5 @@ public class PixelFormatYuv420P : IPixelFormat
{
public string Name => PixelFormat.YUV420P;
public string FFmpegName => FFmpegFormat.YUV420P;
public int BitDepth => 8;
}

View File

@@ -4,4 +4,5 @@ public class PixelFormatYuv420P10Le : IPixelFormat
{
public string Name => PixelFormat.YUV420P10LE;
public string FFmpegName => FFmpegFormat.P010LE;
public int BitDepth => 10;
}

View File

@@ -4,4 +4,5 @@ public class PixelFormatYuv444P : IPixelFormat
{
public string Name => PixelFormat.YUV444P;
public string FFmpegName => FFmpegFormat.YUV444P;
public int BitDepth => 8;
}

View File

@@ -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;
}

View File

@@ -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>();
}
}
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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