Compare commits

...

38 Commits

Author SHA1 Message Date
Jason Dove
d91e945124 update changelog for release v0.8.3-beta [no ci] 2023-11-22 11:36:31 -06:00
Jason Dove
9dabffbac1 support more formats for show fallback metadata (#1514) 2023-11-21 15:52:25 -06:00
Jason Dove
d310b5c09d fix nvidia hardware decoding on windows (#1513) 2023-11-21 06:36:05 -06:00
Jason Dove
ba48b3a676 update dependencies (#1512) 2023-11-20 21:57:43 -06:00
Jason Dove
d8a51b5d6d fix season display bug (#1511) 2023-11-20 21:17:11 -06:00
Jason Dove
97674cff89 fix bug scheduling duration filler (#1510) 2023-11-20 21:02:26 -06:00
Jason Dove
4820615308 proper fix to the sdk mismatch (#1509) 2023-11-16 13:37:20 -06:00
Jason Dove
1ddf27ce88 pin dotnet sdk version used in github actions (#1508) 2023-11-16 13:21:51 -06:00
Jason Dove
cd98a89acd enable docker arm builds again (#1507) 2023-11-16 13:07:49 -06:00
Jason Dove
a2a6afc3e3 temp disable arm docker builds (#1506) 2023-11-16 09:58:46 -06:00
Jason Dove
dfaba8c7b0 use release version of ffmpeg 6.1 (#1505) 2023-11-16 09:57:13 -06:00
Jason Dove
5d11a6b46f use separate model for plex collection scanning since the api types are inconsistent (#1504) 2023-11-16 06:43:48 -06:00
Jason Dove
b95a89b11f plex collection rework (#1503)
* start to rework plex collection scanning

* sync plex collections to db

* sync plex collection items

* update changelog
2023-11-14 10:41:21 -06:00
Jason Dove
948b3735bd fix file not found music videos (#1502)
* fix indexing music videos in file not found state

* update dependencies
2023-11-14 05:50:51 -06:00
Jason Dove
5ecf271773 fix jellyfin library scan (#1501)
* update dependencies

* fix jellyfin library scan
2023-11-10 06:26:23 -06:00
Jason Dove
b287c0d6ec add jellyfin season number fallback (#1497) 2023-11-06 09:37:12 -06:00
Jason Dove
b667659c05 use notarytool directly instead of gon (#1493) 2023-11-05 07:46:15 -06:00
Jason Dove
22d3025e8e include noto cjk fonts in docker (#1492) 2023-11-05 06:15:57 -06:00
Jason Dove
8f5b181372 mysql media server library scan fixes (#1491)
* fix some mysql movie library updates

* fix some mysql show library updates

* update dependencies
2023-10-30 06:45:00 -05:00
Jason Dove
f5060522aa windows nvidia h264 workaround (#1487)
* work around bad h264_cuvid behavior on windows with ffmpeg snapshot

* use latest ffmpeg build on windows

* nvdec => cuda
2023-10-16 11:40:12 -05:00
Jason Dove
14a88bd225 optimize ffmpeg capability cache (#1486)
* minimize cached ffmpeg capabilities

* use set intersect

* try disabling work ahead on nvidia/windows
2023-10-16 08:42:26 -05:00
Jason Dove
0550c60a78 allow older ffmpeg for testing (#1485)
* allow older ffmpeg for testing

* use proper option name
2023-10-14 21:13:18 -05:00
Jason Dove
d3bdcf9bc4 allow plex personal media show libraries (#1483) 2023-10-13 13:33:10 -05:00
Jason Dove
714f68a887 add language_tag and seconds fields to search index (#1479)
* add `language_tag` and `seconds` fields to search index

* simplify
2023-10-10 20:36:50 -05:00
Jason Dove
17bed524f2 fix ui display of multiple languages (#1474) 2023-10-08 18:21:48 -05:00
Jason Dove
c3fe263978 validate hardware accel, use hw accel for error messages (#1471)
* only display supported hw accels in ffmpeg profile editor

* qsv capability improvements

* qsv fixes

* update changelog
2023-10-08 11:21:04 -05:00
Jason Dove
5291832e6c fix clipboard and logs (#1466)
* fix copy to clipboard in some cases

* improve subtitle language selection logging

* log playout item details
2023-10-06 19:36:42 -05:00
Jason Dove
b39dd693f0 update dependencies and windows ffmpeg (#1462)
* update dependencies

* update windows ffmpeg version
2023-10-05 19:14:06 -05:00
Jason Dove
46bf9ef990 fix intel vaapi pgs subtitle pixel format (#1455) 2023-09-30 13:10:23 -05:00
Jason Dove
bc845b1327 schedule filler using ticks instead of milliseconds (#1454)
* add script to set db provider

* don't extract embedded subtitles with DEBUG_NO_SYNC

* fix playout filler precision bug
2023-09-30 06:41:15 -05:00
Jason Dove
3ab8e5bc3a optimize jellyfin collection scanning (#1453) 2023-09-29 09:47:57 -05:00
Jason Dove
e8bc051f73 transcoding improvements (#1452)
* use noautoscale with vaapi encoder

* only use one input file for vaapi with radeonsi driver

* fix vaapi 8-bit to 10-bit

* fix nvidia subtitle scaling

* optimize nvidia subtitle scaling

* fix test pgs subtitle
2023-09-29 06:29:59 -05:00
Jason Dove
b008fcfd85 fix scheduling precision error (#1451)
* fix scheduling precision error

* update dependencies
2023-09-27 06:07:48 -05:00
Jason Dove
547db5fb51 add kodiprop to channels.m3u (#1448) 2023-09-26 15:47:55 -05:00
Jason Dove
58fae1b0cc add crop scaling behavior (#1443)
* add scaling behavior - crop

* fix ffmpeg version check on windows (snapshot)

* update dependencies
2023-09-22 08:23:49 -05:00
Jason Dove
694b6bbd91 scaling behavior and normalize loudness (#1439)
* update changelog [no ci]

* add ffmpeg profile scaling behavior

* update dependencies

* add normalize loudness mode

* update changelog
2023-09-21 02:46:43 -05:00
Jason Dove
e0f8b7d7ae use ffmpeg 6.1 snapshot for windows (#1435) 2023-09-14 19:33:40 -05:00
Jason Dove
b16215fcd6 improve hls throttle (#1434)
* throttle using ffmpeg option

* update ffmpeg version
2023-09-14 19:28:15 -05:00
195 changed files with 47729 additions and 680 deletions

View File

@@ -118,12 +118,8 @@ jobs:
- name: Notarize
shell: bash
run: |
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 }}
xcrun notarytool submit ErsatzTV.dmg --apple-id "${{ secrets.ac_username }}" --password "${{ secrets.ac_password }}" --team-id 32MB98Q32R --wait
xcrun stapler staple ErsatzTV.dmg
- name: Cleanup
shell: bash
@@ -208,7 +204,7 @@ jobs:
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z"
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/6.1-working-cuvid/ffmpeg-6.1-working-cuvid.7z"
target: ffmpeg/
- name: Build
@@ -245,9 +241,6 @@ jobs:
# Delete output directory
rm -r "$release_name"
env:
AC_USERNAME: ${{ secrets.ac_username }}
AC_PASSWORD: ${{ secrets.ac_password }}
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
@@ -259,6 +252,7 @@ jobs:
assets: |
*${{ matrix.target }}.zip
*${{ matrix.target }}.tar.gz
- name: Publish
uses: softprops/action-gh-release@v1
with:

View File

@@ -5,6 +5,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.8.3-beta] - 2023-11-22
### Added
- Add `Scaling Behavior` option to FFmpeg Profile
- `Scale and Pad`: the default behavior and will maintain aspect ratio of all content
- `Stretch`: a new mode that will NOT maintain aspect ratio when normalizing source content to the desired resolution
- `Crop`: a new mode that will scale beyond the desired resolution (maintaining aspect ratio), and crop to desired resolution
- **This mode does NOT detect black and intelligently crop**
- The goal is to fill the canvas by over-scaling and cropping, instead of minimally scaling and padding
- Include `inputstream.ffmpegdirect` properties in channels.m3u when requested by Kodi
- Log playout item title and path when starting a stream
- This will help with media server libraries where the URL passed to ffmpeg doesn't indicate which file is streaming
- Add QSV Capabilities to Troubleshooting page
- Add `language_tag` and `seconds` fields to search index
- Allow synchronizing Plex `TV Show` libraries that use `Personal Media Shows` agent
- Include Noto CJK Fonts in docker images to support those characters in generated subtitles like songs and music video credits
- Support show fallback metadata with folder names like `Show.Name(1992)`
### Fixed
- Fix playout bug that caused some schedule items with fixed start times to be pushed to the next day
- Fix playout bug that prevented padded durations from fitting within a schedule item of the same duration
- For example, filler that padded to 30 minutes would often not fit in a 30 minute duration schedule item
- Fix VAAPI transcoding 8-bit source content to 10-bit
- Fix NVIDIA subtitle scaling when `scale_npp` filter is unavailable
- Remove ffmpeg and ffprobe as required dependencies for scanning media server libraries
- Note that ffmpeg is still *always* required for playback to work
- Fix PGS subtitle pixel format with Intel VAAPI
- Fix some cases where `Copy` button would fail to copy to clipboard
- Fix some cases where ffmpeg process would remain running after properly closing ErsatzTV
- Fix QSV HLS segment duration
- This behavior caused extremely slow QSV stream starts
- Fix displaying multiple languages in UI for movies, artists, shows
- Fix MySQL queries that could fail during media server library scans
- Fix scanning Jellyfin libraries when library options and/or path infos are not returned from Jellyfin API
- Fix error indexing music videos in `File Not Found` state
- Fix bug scheduling duration filler when filler collection contains item with zero duration
- Fix bug displaying television seasons for shows that have no year metadata
### Changed
- Upgrade ffmpeg to 6.1, which is now *required* for all installs
- Use new ffmpeg throttling method to minimize cpu/gpu use without impacting audio normalization
- Change FFmpeg Profile `Normalize Loudness` setting from checkbox to dropdown
- `Off`: do not normalize loudness
- `loudnorm`: use `loudnorm` filter to normalize loudness (generally higher CPU use)
- `dynaudnorm`: use `dynaudnorm` filter to normalize loudness (generally lower CPU use)
- Jellyfin collection scanning will no longer happen after every (automatic or forced) library scan
- Automatic/periodic scans will check collections one time after all libraries have been scanned
- There is a new table in the `Media` > `Libraries` page with a button to manually re-scan Jellyfin collections as needed
- In FFmpeg Profile editor, only display hardware acceleration kinds that are supported by the configured ffmpeg
- Test QSV acceleration if configured, and fallback to software mode if test fails
- Detect QSV capabilities on Linux (supported decoders, encoders)
- Use hardware acceleration for error messages/offline messages
- Try to parse season number from season folder when Jellyfin does not provide season number
- This *may* fix issues where Jellyfin libraries show all season numbers as 0 (specials)
- Rework Plex collection scanning
- Automatic/periodic scans will check collections one time after all libraries have been scanned
- There is a table in the `Media` > `Libraries` page with a button to manually re-scan Plex collections as needed
- Plex smart collections will now be synchronized as tags, similar to other Plex collections
## [0.8.2-beta] - 2023-09-14
### Added
- Automatically rebuild search index after improper shutdown
@@ -1741,7 +1799,8 @@ 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/ErsatzTV/ErsatzTV/compare/v0.8.2-beta...HEAD
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.3-beta...HEAD
[0.8.3-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.2-beta...v0.8.3-beta
[0.8.2-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.1-beta...v0.8.2-beta
[0.8.1-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.0-beta...v0.8.1-beta
[0.8.0-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.7.9-beta...v0.8.0-beta

View File

@@ -29,12 +29,11 @@ internal static class Mapper
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.Distinct()
.ToList();
}
}

View File

@@ -3,4 +3,4 @@ using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public record GetChannelPlaylist
(string Scheme, string Host, string BaseUrl, string Mode, string AccessToken) : IRequest<ChannelPlaylist>;
(string Scheme, string Host, string BaseUrl, string Mode, string UserAgent, string AccessToken) : IRequest<ChannelPlaylist>;

View File

@@ -20,6 +20,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
request.Host,
request.BaseUrl,
channels,
request.UserAgent,
request.AccessToken));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)

View File

@@ -14,13 +14,14 @@
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.7.30">
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.8.14">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="2.0.0" />
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="3.0.0" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -12,6 +12,7 @@ public record CreateFFmpegProfile(
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FFmpegProfileVideoFormat VideoFormat,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
@@ -19,7 +20,7 @@ public record CreateFFmpegProfile(
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
NormalizeLoudnessMode NormalizeLoudnessMode,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,

View File

@@ -46,6 +46,7 @@ public class CreateFFmpegProfileHandler :
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
VideoFormat = request.VideoFormat,
BitDepth = request.BitDepth,
VideoBitrate = request.VideoBitrate,
@@ -53,7 +54,7 @@ public class CreateFFmpegProfileHandler :
AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudness = request.NormalizeLoudness,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,

View File

@@ -13,6 +13,7 @@ public record UpdateFFmpegProfile(
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FFmpegProfileVideoFormat VideoFormat,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
@@ -20,7 +21,7 @@ public record UpdateFFmpegProfile(
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
NormalizeLoudnessMode NormalizeLoudnessMode,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,

View File

@@ -35,6 +35,7 @@ public class
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.VideoFormat = update.VideoFormat;
// mpeg2video only supports 8-bit content
@@ -47,7 +48,7 @@ public class
p.AudioFormat = update.AudioFormat;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudness = update.NormalizeLoudness;
p.NormalizeLoudnessMode = update.NormalizeLoudnessMode;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate;

View File

@@ -13,6 +13,7 @@ public record FFmpegProfileViewModel(
string VaapiDevice,
int? QsvExtraHardwareFrames,
ResolutionViewModel Resolution,
ScalingBehavior ScalingBehavior,
FFmpegProfileVideoFormat VideoFormat,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
@@ -20,7 +21,7 @@ public record FFmpegProfileViewModel(
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
NormalizeLoudnessMode NormalizeLoudnessMode,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,

View File

@@ -15,6 +15,7 @@ internal static class Mapper
profile.VaapiDevice,
profile.QsvExtraHardwareFrames,
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
profile.ScalingBehavior,
profile.VideoFormat,
profile.BitDepth,
profile.VideoBitrate,
@@ -22,7 +23,7 @@ internal static class Mapper
profile.AudioFormat,
profile.AudioBitrate,
profile.AudioBufferSize,
profile.NormalizeLoudness,
profile.NormalizeLoudnessMode,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeFramerate,
@@ -51,7 +52,7 @@ internal static class Mapper
(int)ffmpegProfile.AudioFormat,
ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize,
ffmpegProfile.NormalizeLoudness,
(int)ffmpegProfile.NormalizeLoudnessMode,
ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles;
public record GetSupportedHardwareAccelerationKinds : IRequest<List<HardwareAccelerationKind>>;

View File

@@ -0,0 +1,79 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles;
public class
GetSupportedHardwareAccelerationKindsHandler : IRequestHandler<GetSupportedHardwareAccelerationKinds,
List<HardwareAccelerationKind>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
public GetSupportedHardwareAccelerationKindsHandler(
IDbContextFactory<TvContext> dbContextFactory,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory)
{
_dbContextFactory = dbContextFactory;
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
}
public async Task<List<HardwareAccelerationKind>> Handle(
GetSupportedHardwareAccelerationKinds request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await Validate(dbContext);
return await validation.Match(
GetHardwareAccelerationKinds,
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
}
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
{
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
{
result.Add(HardwareAccelerationKind.Nvenc);
}
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Qsv))
{
result.Add(HardwareAccelerationKind.Qsv);
}
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Vaapi))
{
result.Add(HardwareAccelerationKind.Vaapi);
}
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.VideoToolbox))
{
result.Add(HardwareAccelerationKind.VideoToolbox);
}
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Amf))
{
result.Add(HardwareAccelerationKind.Amf);
}
return result;
}
private static async Task<Validation<BaseError, string>> Validate(TvContext dbContext) =>
await FFmpegPathMustExist(dbContext);
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
}

View File

@@ -0,0 +1,83 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinCollections>,
IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
{
public CallJellyfinCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
});
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeJellyfinCollections request)
{
DateTime minDateTime = await dbContext.JellyfinMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizeJellyfinCollections request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-jellyfin-collections", request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
}
}

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

View File

@@ -15,20 +15,51 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
GetExternalCollections request,
CancellationToken cancellationToken)
{
List<LibraryViewModel> result = new();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<int> mediaSourceIds = await dbContext.EmbyMediaSources
result.AddRange(await GetEmbyExternalCollections(dbContext, cancellationToken));
result.AddRange(await GetJellyfinExternalCollections(dbContext, cancellationToken));
result.AddRange(await GetPlexExternalCollections(dbContext, cancellationToken));
return result;
}
private static async Task<IEnumerable<LibraryViewModel>> GetEmbyExternalCollections(
TvContext dbContext,
CancellationToken cancellationToken)
{
List<int> embyMediaSourceIds = await dbContext.EmbyMediaSources
.Filter(ems => ems.Libraries.Any(l => ((EmbyLibrary)l).ShouldSyncItems))
.Map(ems => ems.Id)
.ToListAsync(cancellationToken);
return mediaSourceIds.Map(
id => new LibraryViewModel(
"Emby",
0,
"Collections",
0,
id,
string.Empty))
.ToList();
return embyMediaSourceIds.Map(id => new LibraryViewModel("Emby", 0, "Collections", 0, id, string.Empty));
}
private static async Task<IEnumerable<LibraryViewModel>> GetJellyfinExternalCollections(
TvContext dbContext,
CancellationToken cancellationToken)
{
List<int> jellyfinMediaSourceIds = await dbContext.JellyfinMediaSources
.Filter(jms => jms.Libraries.Any(l => ((JellyfinLibrary)l).ShouldSyncItems))
.Map(jms => jms.Id)
.ToListAsync(cancellationToken);
return jellyfinMediaSourceIds.Map(
id => new LibraryViewModel("Jellyfin", 0, "Collections", 0, id, string.Empty));
}
private static async Task<IEnumerable<LibraryViewModel>> GetPlexExternalCollections(
TvContext dbContext,
CancellationToken cancellationToken)
{
List<int> plexMediaSourceIds = await dbContext.PlexMediaSources
.Filter(pms => pms.Libraries.Any(l => ((PlexLibrary)l).ShouldSyncItems))
.Map(pms => pms.Id)
.ToListAsync(cancellationToken);
return plexMediaSourceIds.Map(
id => new LibraryViewModel("Plex", 0, "Collections", 0, id, string.Empty));
}
}

View File

@@ -46,13 +46,12 @@ internal static class Mapper
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languageCodes
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.Map(ci => ci.EnglishName)
.Distinct()
.ToList();
}

View File

@@ -0,0 +1,83 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Plex;
public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<SynchronizePlexCollections>,
IRequestHandler<SynchronizePlexCollections, Either<BaseError, Unit>>
{
public CallPlexCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizePlexCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
});
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizePlexCollections request)
{
DateTime minDateTime = await dbContext.PlexMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexMediaSourceId)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizePlexCollections request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
SynchronizePlexCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-plex-collections", request.PlexMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
}
}

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

View File

@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexLibraries(int PlexMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IPlexBackgroundServiceRequest;
IScannerBackgroundServiceRequest;

View File

@@ -15,7 +15,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
{
private const string LocalhostUri = "http://localhost:32400";
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
@@ -28,7 +28,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
IPlexTvApiClient plexTvApiClient,
IPlexServerApiClient plexServerApiClient,
IPlexSecretStore plexSecretStore,
ChannelWriter<IPlexBackgroundServiceRequest> channel,
ChannelWriter<IScannerBackgroundServiceRequest> channel,
IEntityLocker entityLocker,
ILogger<SynchronizePlexMediaSourcesHandler> logger)
{

View File

@@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Streaming;
@@ -18,6 +19,7 @@ namespace ErsatzTV.Application.Streaming;
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly IServiceScopeFactory _serviceScopeFactory;
@@ -38,6 +40,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
ILogger<HlsSessionWorker> sessionWorkerLogger,
IFFmpegSegmenterService ffmpegSegmenterService,
IConfigElementRepository configElementRepository,
IHostApplicationLifetime hostApplicationLifetime,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
_hlsPlaylistFilter = hlsPlaylistFilter;
@@ -49,6 +52,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_sessionWorkerLogger = sessionWorkerLogger;
_ffmpegSegmenterService = ffmpegSegmenterService;
_configElementRepository = configElementRepository;
_hostApplicationLifetime = hostApplicationLifetime;
_workerChannel = workerChannel;
}
@@ -81,7 +85,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
// fire and forget worker
_ = worker.Run(request.ChannelNumber, idleTimeout, cancellationToken)
_ = worker.Run(request.ChannelNumber, idleTimeout, _hostApplicationLifetime.ApplicationStopping)
.ContinueWith(
_ =>
{

View File

@@ -19,8 +19,6 @@ namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorker : IHlsSessionWorker
{
public static readonly TimeSpan WorkAheadDuration = TimeSpan.FromMinutes(3);
private static readonly SemaphoreSlim Slim = new(1, 1);
private static int _workAheadCount;
private readonly IMediator _mediator;

View File

@@ -1,5 +1,6 @@
using CliWrap;
using Dapper;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
@@ -83,6 +84,10 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Subtitles)
.Include(i => i.MediaItem)
@@ -142,6 +147,22 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
foreach (PlayoutItemWithPath playoutItemWithPath in maybePlayoutItem.RightToSeq())
{
try
{
PlayoutItemViewModel viewModel = Mapper.ProjectToViewModel(playoutItemWithPath.PlayoutItem);
if (!string.IsNullOrWhiteSpace(viewModel.Title))
{
_logger.LogDebug(
"Found playout item {Title} with path {Path}",
viewModel.Title,
playoutItemWithPath.Path);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get playout item title");
}
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
string videoPath = playoutItemWithPath.Path;
@@ -178,19 +199,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
TimeSpan outPoint = playoutItemWithPath.PlayoutItem.OutPoint;
DateTimeOffset effectiveNow = request.StartAtZero ? start : now;
TimeSpan duration = finish - effectiveNow;
var isComplete = true;
// _logger.LogDebug("PRE Start: {Start}, Finish {Finish}", start, finish);
// _logger.LogDebug("PRE in: {In}, out: {Out}", inPoint, outPoint);
if (!request.HlsRealtime && duration > HlsSessionWorker.WorkAheadDuration)
{
finish = effectiveNow + HlsSessionWorker.WorkAheadDuration;
outPoint = finish - start + inPoint;
isComplete = false;
// _logger.LogDebug("POST Start: {Start}, Finish {Finish}", start, finish);
// _logger.LogDebug("POST in: {In}, out: {Out}", inPoint, outPoint);
}
Command process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
@@ -223,7 +231,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItemWithPath.PlayoutItem.DisableWatermarks,
_ => { });
var result = new PlayoutItemProcessModel(process, duration, finish, isComplete);
var result = new PlayoutItemProcessModel(process, duration, finish, true);
return Right<BaseError, PlayoutItemProcessModel>(result);
}

View File

@@ -104,12 +104,11 @@ internal static class Mapper
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.Distinct()
.ToList();
}
}

View File

@@ -7,6 +7,7 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Capabilities.Qsv;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -74,6 +75,7 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
.ToList();
string nvidiaCapabilities = null;
string qsvCapabilities = null;
string vaapiCapabilities = null;
Option<ConfigElement> maybeFFmpegPath =
await _configElementRepository.GetConfigElement(ConfigElementKey.FFmpegPath);
@@ -87,6 +89,20 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
{
nvidiaCapabilities = await _hardwareCapabilitiesFactory.GetNvidiaOutput(ffmpegPath.Value);
if (!_memoryCache.TryGetValue("ffmpeg.render_devices", out List<string> vaapiDevices))
{
vaapiDevices = new List<string> { "/dev/dri/renderD128" };
}
foreach (string qsvDevice in vaapiDevices)
{
QsvOutput output = await _hardwareCapabilitiesFactory.GetQsvOutput(ffmpegPath.Value, qsvDevice);
qsvCapabilities += $"Checking device {qsvDevice}{Environment.NewLine}";
qsvCapabilities += $"Exit Code: {output.ExitCode}{Environment.NewLine}{Environment.NewLine}";
qsvCapabilities += output.Output;
qsvCapabilities += Environment.NewLine + Environment.NewLine;
}
if (_runtimeInfo.IsOSPlatform(OSPlatform.Linux))
{
var allDrivers = new List<VaapiDriver>
@@ -94,11 +110,6 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
foreach (VaapiDriver activeDriver in allDrivers)
{
if (!_memoryCache.TryGetValue("ffmpeg.render_devices", out List<string> vaapiDevices))
{
vaapiDevices = new List<string> { "/dev/dri/renderD128" };
}
foreach (string vaapiDevice in vaapiDevices)
{
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
@@ -123,6 +134,7 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
activeFFmpegProfiles,
channels,
nvidiaCapabilities,
qsvCapabilities,
vaapiCapabilities);
}

View File

@@ -10,4 +10,5 @@ public record TroubleshootingInfo(
IEnumerable<FFmpegProfile> FFmpegProfiles,
IEnumerable<Channel> Channels,
string NvidiaCapabilities,
string QsvCapabilities,
string VaapiCapabilities);

View File

@@ -10,21 +10,21 @@
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.6.4" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.4" />
<PackageReference Include="LanguageExt.Core" Version="4.4.7" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.7.30">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.8.14">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="Serilog" Version="3.0.1" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.Extensions.Logging" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
</ItemGroup>

View File

@@ -886,7 +886,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeLoudness = true
NormalizeLoudnessMode = NormalizeLoudnessMode.LoudNorm
};
FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings(
@@ -902,7 +902,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
false,
None);
actual.NormalizeLoudness.Should().BeTrue();
actual.NormalizeLoudnessMode.Should().Be(NormalizeLoudnessMode.LoudNorm);
}
}

View File

@@ -1,10 +1,11 @@
using ErsatzTV.Core.Domain;
using Destructurama;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Scheduling;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Serilog;
namespace ErsatzTV.Core.Tests.Scheduling;
@@ -15,6 +16,20 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
private CancellationToken _cancellationToken;
private readonly ILogger<PlayoutModeSchedulerDuration> _logger;
public PlayoutModeSchedulerDurationTests()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.Destructure.UsingAttributes()
.CreateLogger();
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
_logger = loggerFactory.CreateLogger<PlayoutModeSchedulerDuration>();
}
[Test]
public void Should_Fill_Exact_Duration()
@@ -44,7 +59,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),
@@ -117,7 +132,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),
@@ -189,7 +204,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),
@@ -258,7 +273,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),
@@ -341,7 +356,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
@@ -428,7 +443,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
@@ -527,7 +542,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
@@ -637,7 +652,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(
@@ -710,6 +725,119 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback);
playoutItems[6].GuideFinish.HasValue.Should().BeFalse();
}
[Test]
public void Should_Not_Have_Gap_With_Post_Roll_Pad_And_Fallback_Filler()
{
Collection collectionPre = TwoItemCollection(1, 2, TimeSpan.Parse("00:00:15.6734470"));
Collection collectionOne = TwoItemCollection(3, 4, TimeSpan.Parse("00:22:58.1220000"));
Collection collectionTwo = CollectionOf(
new Dictionary<int, TimeSpan>
{
{ 5, TimeSpan.Parse("00:00:31.3004760") },
{ 6, TimeSpan.Parse("00:00:31.7880950") },
{ 7, TimeSpan.Parse("00:00:31.1147170") },
{ 8, TimeSpan.Parse("00:00:46.4863270") },
{ 9, TimeSpan.Parse("00:00:31.4165760") },
{ 10, TimeSpan.Parse("00:00:31.5791160") },
{ 11, TimeSpan.Parse("00:00:31.2540360") },
{ 12, TimeSpan.Parse("00:00:36.2231070") },
{ 13, TimeSpan.Parse("00:02:00.0471430") },
});
Collection collectionThree = TwoItemCollection(14, 15, TimeSpan.Parse("00:00:55.6349890"));
var scheduleItem = new ProgramScheduleItemDuration
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromMinutes(30),
PlaybackOrder = PlaybackOrder.Chronological,
PreRollFiller = new FillerPreset
{
FillerKind = FillerKind.PreRoll,
FillerMode = FillerMode.Count,
Count = 1,
Collection = collectionPre,
CollectionId = collectionPre.Id
},
PostRollFiller = new FillerPreset
{
FillerKind = FillerKind.PostRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 30,
Collection = collectionTwo,
CollectionId = collectionTwo.Id
},
FallbackFiller = new FillerPreset
{
FillerKind = FillerKind.Fallback,
Collection = collectionThree,
CollectionId = collectionThree.Id
}
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
new List<ProgramScheduleItem> { scheduleItem },
new CollectionEnumeratorState());
var enumerator1 = new ChronologicalMediaCollectionEnumerator(
collectionPre.MediaItems,
new CollectionEnumeratorState());
var enumerator2 = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var enumerator3 = new ChronologicalMediaCollectionEnumerator(
collectionTwo.MediaItems,
new CollectionEnumeratorState());
var enumerator4 = new ChronologicalMediaCollectionEnumerator(
collectionThree.MediaItems,
new CollectionEnumeratorState());
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(
scheduleItem,
enumerator2,
scheduleItem.PreRollFiller,
enumerator1,
scheduleItem.PostRollFiller,
enumerator3,
scheduleItem.FallbackFiller,
enumerator4),
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_cancellationToken);
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddMinutes(30));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
// THIS IS THE KEY TEST - needs to be exactly 30 minutes
(playoutItems.Last().FinishOffset - playoutItems.First().StartOffset).Should().Be(TimeSpan.FromMinutes(30));
// playoutBuilderState.NextGuideGroup.Should().Be(3);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
enumerator1.State.Index.Should().Be(1);
enumerator2.State.Index.Should().Be(1);
enumerator3.State.Index.Should().Be(0);
enumerator4.State.Index.Should().Be(1);
playoutItems.Count.Should().Be(12);
}
[Test]
public void Should_Not_Schedule_At_HardStop()
@@ -745,7 +873,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
var scheduler = new PlayoutModeSchedulerDuration(_logger);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
startState,
CollectionEnumerators(scheduleItem, enumerator),

View File

@@ -45,6 +45,23 @@ public abstract class SchedulerTestBase
{ CollectionKey.ForFillerPreset(fillerPreset), enumerator2 },
{ CollectionKey.ForFillerPreset(fillerPreset2), enumerator3 }
};
protected static Dictionary<CollectionKey, IMediaCollectionEnumerator> CollectionEnumerators(
ProgramScheduleItem scheduleItem,
IMediaCollectionEnumerator enumerator1,
FillerPreset fillerPreset,
IMediaCollectionEnumerator enumerator2,
FillerPreset fillerPreset2,
IMediaCollectionEnumerator enumerator3,
FillerPreset fillerPreset3,
IMediaCollectionEnumerator enumerator4) =>
new()
{
{ CollectionKey.ForScheduleItem(scheduleItem), enumerator1 },
{ CollectionKey.ForFillerPreset(fillerPreset), enumerator2 },
{ CollectionKey.ForFillerPreset(fillerPreset2), enumerator3 },
{ CollectionKey.ForFillerPreset(fillerPreset3), enumerator4 }
};
private static Movie TestMovie(int id, TimeSpan duration, DateTime aired, int chapterCount = 0)
{
@@ -81,6 +98,22 @@ public abstract class SchedulerTestBase
TestMovie(id2, duration, new DateTime(2020, 1, 2), chapterCount)
}
};
protected static Collection CollectionOf(IDictionary<int, TimeSpan> idsAndDurations, int chapterCount = 0)
{
var mediaItems = new List<MediaItem>();
foreach ((int id, TimeSpan duration) in idsAndDurations)
{
mediaItems.Add(TestMovie(id, duration, new DateTime(2020, 1, id), chapterCount));
}
return new Collection
{
Id = idsAndDurations.Head().Key,
Name = $"Collection of Items {idsAndDurations.Head().Key}",
MediaItems = mediaItems
};
}
protected static Dictionary<CollectionKey, IMediaCollectionEnumerator> CollectionEnumerators(
ProgramScheduleItem scheduleItem,

View File

@@ -14,7 +14,7 @@ public record FFmpegFullProfileResponseModel(
int AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int NormalizeLoudnessMode,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,

View File

@@ -0,0 +1,12 @@
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.Domain;
[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix")]
public class PlexCollection
{
public int Id { get; set; }
public string Key { get; set; }
public string Etag { get; set; }
public string Name { get; set; }
}

View File

@@ -13,6 +13,7 @@ public record FFmpegProfile
public int? QsvExtraHardwareFrames { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
public ScalingBehavior ScalingBehavior { get; set; }
public FFmpegProfileVideoFormat VideoFormat { get; set; }
public FFmpegProfileBitDepth BitDepth { get; set; }
public int VideoBitrate { get; set; }
@@ -20,7 +21,7 @@ public record FFmpegProfile
public FFmpegProfileAudioFormat AudioFormat { get; set; }
public int AudioBitrate { get; set; }
public int AudioBufferSize { get; set; }
public bool NormalizeLoudness { get; set; }
public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; }
public int AudioChannels { get; set; }
public int AudioSampleRate { get; set; }
public bool NormalizeFramerate { get; set; }
@@ -39,7 +40,7 @@ public record FFmpegProfile
VideoBufferSize = 4000,
AudioBitrate = 192,
AudioBufferSize = 384,
NormalizeLoudness = true,
NormalizeLoudnessMode = NormalizeLoudnessMode.DynAudNorm,
AudioChannels = 2,
AudioSampleRate = 48,
DeinterlaceVideo = true,

View File

@@ -6,4 +6,5 @@ public class JellyfinMediaSource : MediaSource
public string OperatingSystem { get; set; }
public List<JellyfinConnection> Connections { get; set; }
public List<JellyfinPathReplacement> PathReplacements { get; set; }
public DateTime? LastCollectionsScan { get; set; }
}

View File

@@ -11,4 +11,5 @@ public class PlexMediaSource : MediaSource
// public bool IsOwned { get; set; }
public List<PlexConnection> Connections { get; set; }
public List<PlexPathReplacement> PathReplacements { get; set; }
public DateTime? LastCollectionsScan { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public enum NormalizeLoudnessMode
{
Off = 0,
LoudNorm = 1,
DynAudNorm = 2
}

View File

@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public enum ScalingBehavior
{
ScaleAndPad = 0,
Stretch = 1,
Crop = 2
}

View File

@@ -12,21 +12,21 @@
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="Destructurama.Attributed" Version="3.1.0" />
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="LanguageExt.Core" Version="4.4.4" />
<PackageReference Include="LanguageExt.Transformers" Version="4.4.4" />
<PackageReference Include="LanguageExt.Core" Version="4.4.7" />
<PackageReference Include="LanguageExt.Transformers" Version="4.4.7" />
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.7.30">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.8.14">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -7,8 +7,9 @@ namespace ErsatzTV.Core.Extensions;
public static class MediaItemExtensions
{
public static Option<TimeSpan> GetDuration(this MediaItem mediaItem) =>
mediaItem switch
public static Option<TimeSpan> GetNonZeroDuration(this MediaItem mediaItem)
{
Option<TimeSpan> maybeDuration = mediaItem switch
{
Movie m => m.MediaVersions.HeadOrNone().Map(v => v.Duration),
Episode e => e.MediaVersions.HeadOrNone().Map(v => v.Duration),
@@ -18,6 +19,10 @@ public static class MediaItemExtensions
_ => None
};
// zero duration is invalid, so return none in that case
return maybeDuration.Any(duration => duration == TimeSpan.Zero) ? Option<TimeSpan>.None : maybeDuration;
}
public static MediaVersion GetHeadVersion(this MediaItem mediaItem) =>
mediaItem switch
{

View File

@@ -150,7 +150,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None,
playbackSettings.NormalizeLoudness);
playbackSettings.NormalizeLoudnessMode switch
{
NormalizeLoudnessMode.LoudNorm => AudioFilter.LoudNorm,
NormalizeLoudnessMode.DynAudNorm => AudioFilter.DynAudNorm,
_ => AudioFilter.None
});
// don't log generated images, or hls direct, which are expected to have unknown format
bool isUnknownPixelFormatExpected =
@@ -301,15 +306,39 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts")
: Option<string>.None;
FrameSize scaledSize = ffmpegVideoStream.SquarePixelFrameSize(
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height));
var paddedSize = new FrameSize(
channel.FFmpegProfile.Resolution.Width,
channel.FFmpegProfile.Resolution.Height);
Option<FrameSize> cropSize = Option<FrameSize>.None;
if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Stretch)
{
scaledSize = paddedSize;
}
if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Crop)
{
paddedSize = ffmpegVideoStream.SquarePixelFrameSizeForCrop(
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height));
cropSize = new FrameSize(
channel.FFmpegProfile.Resolution.Width,
channel.FFmpegProfile.Resolution.Height);
}
var desiredState = new FrameState(
playbackSettings.RealtimeOutput,
fillerKind == FillerKind.Fallback,
videoFormat,
Optional(videoStream.Profile),
Optional(playbackSettings.PixelFormat),
ffmpegVideoStream.SquarePixelFrameSize(
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)),
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height),
scaledSize,
paddedSize,
cropSize,
false,
playbackSettings.FrameRate,
playbackSettings.VideoBitrate,
@@ -403,7 +432,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
Option<TimeSpan>.None,
false);
AudioFilter.None);
var desiredState = new FrameState(
playbackSettings.RealtimeOutput,
@@ -413,6 +442,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
new PixelFormatYuv420P(),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
Option<FrameSize>.None,
false,
playbackSettings.FrameRate,
playbackSettings.VideoBitrate,
@@ -450,11 +480,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
var videoInputFile = new VideoInputFile(videoPath, new List<VideoStream> { ffmpegVideoStream });
// TODO: ignore accel if this already failed once
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, FillerKind.None);
_logger.LogDebug("HW accel mode: {HwAccel}", hwAccel);
var ffmpegState = new FFmpegState(
false,
hwAccel,
HardwareAccelerationMode.None, // no hw accel decode since errors loop
hwAccel,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
@@ -732,7 +764,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
audioInputFile,
watermarkInputFile,
concatInputFile,
pipeline.PipelineSteps);
pipeline.PipelineSteps,
pipeline.IsIntelVaapiOrQsv);
if (environmentVariables.Any())
{

View File

@@ -26,6 +26,6 @@ public class FFmpegPlaybackSettings
public FFmpegProfileAudioFormat AudioFormat { get; set; }
public bool Deinterlace { get; set; }
public Option<int> VideoTrackTimeScale { get; set; }
public bool NormalizeLoudness { get; set; }
public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; }
public Option<int> FrameRate { get; set; }
}

View File

@@ -158,7 +158,7 @@ public static class FFmpegPlaybackSettingsCalculator
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = outPoint - inPoint;
result.NormalizeLoudness = ffmpegProfile.NormalizeLoudness;
result.NormalizeLoudnessMode = ffmpegProfile.NormalizeLoudnessMode;
result.Deinterlace = ffmpegProfile.DeinterlaceVideo == true &&
videoVersion.VideoScanKind == VideoScanKind.Interlaced;
@@ -175,8 +175,7 @@ public static class FFmpegPlaybackSettingsCalculator
bool hlsRealtime) =>
new()
{
// HardwareAcceleration = ffmpegProfile.HardwareAcceleration,
HardwareAcceleration = HardwareAccelerationKind.None,
HardwareAcceleration = ffmpegProfile.HardwareAcceleration,
FormatFlags = CommonFormatFlags,
VideoFormat = ffmpegProfile.VideoFormat,
VideoBitrate = ffmpegProfile.VideoBitrate,

View File

@@ -138,6 +138,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
return None;
}
var allCodes = new List<string>();
string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(language))
{
@@ -146,10 +147,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
else
{
// filter to preferred language
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
if (allCodes.Count > 1)
{
_logger.LogDebug("Preferred subtitle language has multiple codes {Codes}", allCodes);
}
subtitles = subtitles
.Filter(
s => allCodes.Any(c => string.Equals(s.Language, c, StringComparison.OrdinalIgnoreCase)))
.Filter(s => allCodes.Any(c => string.Equals(s.Language, c, StringComparison.OrdinalIgnoreCase)))
.ToList();
}
@@ -185,7 +190,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
"Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}",
channel.Number,
subtitleMode,
preferredSubtitleLanguage);
allCodes);
return None;
}

View File

@@ -7,6 +7,8 @@ public interface IEntityLocker
event EventHandler<Type> OnRemoteMediaSourceChanged;
event EventHandler OnTraktChanged;
event EventHandler OnEmbyCollectionsChanged;
event EventHandler OnJellyfinCollectionsChanged;
event EventHandler OnPlexCollectionsChanged;
event EventHandler<int> OnPlayoutChanged;
bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId);
@@ -23,6 +25,12 @@ public interface IEntityLocker
bool LockEmbyCollections();
bool UnlockEmbyCollections();
bool AreEmbyCollectionsLocked();
bool LockJellyfinCollections();
bool UnlockJellyfinCollections();
bool AreJellyfinCollectionsLocked();
bool LockPlexCollections();
bool UnlockPlexCollections();
bool ArePlexCollectionsLocked();
bool LockPlayout(int playoutId);
bool UnlockPlayout(int playoutId);
bool IsPlayoutLocked(int playoutId);

View File

@@ -0,0 +1,12 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Plex;
public interface IPlexCollectionScanner
{
Task<Either<BaseError, Unit>> ScanCollections(
PlexConnection connection,
PlexServerAuthToken token,
CancellationToken cancellationToken);
}

View File

@@ -67,4 +67,15 @@ public interface IPlexServerApiClient
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
IAsyncEnumerable<PlexCollection> GetAllCollections(
PlexConnection connection,
PlexServerAuthToken token,
CancellationToken cancellationToken);
IAsyncEnumerable<MediaItem> GetCollectionItems(
PlexConnection connection,
PlexServerAuthToken token,
string key,
CancellationToken cancellationToken);
}

View File

@@ -8,7 +8,7 @@ public interface IMediaServerMovieRepository<in TLibrary, TMovie, TEtag> where T
where TEtag : MediaServerItemEtag
{
Task<List<TEtag>> GetExistingMovies(TLibrary library);
Task<bool> FlagNormal(TLibrary library, TMovie movie);
Task<Option<int>> FlagNormal(TLibrary library, TMovie movie);
Task<Option<int>> FlagUnavailable(TLibrary library, TMovie movie);
Task<Option<int>> FlagRemoteOnly(TLibrary library, TMovie movie);
Task<List<int>> FlagFileNotFound(TLibrary library, List<string> movieItemIds);

View File

@@ -18,9 +18,9 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
Task<Unit> SetEtag(TShow show, string etag);
Task<Unit> SetEtag(TSeason season, string etag);
Task<Unit> SetEtag(TEpisode episode, string etag);
Task<bool> FlagNormal(TLibrary library, TEpisode episode);
Task<bool> FlagNormal(TLibrary library, TSeason season);
Task<bool> FlagNormal(TLibrary library, TShow show);
Task<Option<int>> FlagNormal(TLibrary library, TEpisode episode);
Task<Option<int>> FlagNormal(TLibrary library, TSeason season);
Task<Option<int>> FlagNormal(TLibrary library, TShow show);
Task<List<int>> FlagFileNotFoundShows(TLibrary library, List<string> showItemIds);
Task<List<int>> FlagFileNotFoundSeasons(TLibrary library, List<string> seasonItemIds);
Task<List<int>> FlagFileNotFoundEpisodes(TLibrary library, List<string> episodeItemIds);

View File

@@ -82,5 +82,7 @@ public interface IMediaSourceRepository
Task<List<int>> DeleteAllEmby();
Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds);
Task<List<int>> DisableEmbyLibrarySync(List<int> libraryIds);
Task<Unit> UpdateLastScan(EmbyMediaSource embyMediaSource);
Task<Unit> UpdateLastCollectionScan(EmbyMediaSource embyMediaSource);
Task<Unit> UpdateLastCollectionScan(JellyfinMediaSource jellyfinMediaSource);
Task<Unit> UpdateLastCollectionScan(PlexMediaSource plexMediaSource);
}

View File

@@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexCollectionRepository
{
Task<List<PlexCollection>> GetCollections();
Task<bool> AddCollection(PlexCollection collection);
Task<bool> RemoveCollection(PlexCollection collection);
Task<List<int>> RemoveAllTags(PlexCollection collection);
Task<int> AddTag(MediaItem item, PlexCollection collection);
Task<bool> SetEtag(PlexCollection collection);
}

View File

@@ -9,15 +9,23 @@ public class ChannelPlaylist
private readonly string _accessToken;
private readonly string _baseUrl;
private readonly List<Channel> _channels;
private readonly string _userAgent;
private readonly string _host;
private readonly string _scheme;
public ChannelPlaylist(string scheme, string host, string baseUrl, List<Channel> channels, string accessToken)
public ChannelPlaylist(
string scheme,
string host,
string baseUrl,
List<Channel> channels,
string userAgent,
string accessToken)
{
_scheme = scheme;
_host = host;
_baseUrl = baseUrl;
_channels = channels;
_userAgent = userAgent;
_accessToken = accessToken;
}
@@ -37,6 +45,20 @@ public class ChannelPlaylist
sb.AppendLine(CultureInfo.InvariantCulture, $"#EXTM3U url-tvg=\"{xmltv}\" x-tvg-url=\"{xmltv}\"");
foreach (Channel channel in _channels.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)))
{
if ((_userAgent ?? string.Empty).StartsWith("kodi", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine("#KODIPROP:inputstream=inputstream.ffmpegdirect");
string mimeType = channel.StreamingMode switch
{
StreamingMode.TransportStream or StreamingMode.TransportStreamHybrid => "video/mp2t",
_ => "application/x-mpegURL"
};
sb.AppendLine(CultureInfo.InvariantCulture, $"#KODIPROP:mimetype={mimeType}");
sb.AppendLine("#KODIPROP:inputstream.ffmpegdirect.open_mode=ffmpeg");
}
string logo = Optional(channel.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()

View File

@@ -350,7 +350,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
{
try
{
const string PATTERN = @"^(.*?)[\s.]+?[.\(](\d{4})[.\)].*$";
const string PATTERN = @"^(.*?)[\s.]*?[.\(](\d{4})[.\)].*$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{

View File

@@ -15,7 +15,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
{
_sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalMediaComparer()).ToList();
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(
() => _sortedMediaItems.Bind(i => i.GetDuration()).OrderBy(identity).HeadOrNone());
() => _sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };

View File

@@ -26,7 +26,7 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
.Map(ci => mediaItems.First(mi => mi.Id == ci.MediaItemId))
.ToList();
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(
() => _sortedMediaItems.Bind(i => i.GetDuration()).OrderBy(identity).HeadOrNone());
() => _sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)

View File

@@ -50,6 +50,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
if (scheduleItem.StartType == StartType.Fixed && !isIncomplete)
{
TimeSpan itemStartTime = scheduleItem.StartTime.GetValueOrDefault();
DateTime date = startTime.Date;
DateTimeOffset result = new DateTimeOffset(
date.Year,
@@ -62,7 +63,11 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Local)))
.Add(itemStartTime);
// DateTimeOffset result = startTime.Date + itemStartTime;
// Serilog.Log.Logger.Debug(
// "StartTimeOfDay: {StartTimeOfDay} Item Start Time: {ItemStartTime}",
// startTime.TimeOfDay.TotalMilliseconds,
// itemStartTime.TotalMilliseconds);
// need to wrap to the next day if appropriate
startTime = startTime.TimeOfDay > itemStartTime ? result.AddDays(1) : result;
}
@@ -345,13 +350,12 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
foreach (FillerPreset padFiller in Optional(
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
{
var totalDuration = TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
var totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks));
// add primary content to totalDuration only if it hasn't already been added
if (result.All(pi => pi.MediaItemId != playoutItem.MediaItemId))
{
totalDuration += TimeSpan.FromMilliseconds(
effectiveChapters.Sum(c => (c.EndTime - c.StartTime).TotalMilliseconds));
totalDuration += TimeSpan.FromTicks(effectiveChapters.Sum(c => (c.EndTime - c.StartTime).Ticks));
}
int currentMinute = (playoutItem.StartOffset + totalDuration).Minute;
@@ -395,8 +399,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
padFiller.AllowWatermarks,
log,
cancellationToken));
totalDuration =
TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks));
remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
if (remainingToFill > TimeSpan.Zero)
{
@@ -486,8 +489,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
padFiller.AllowWatermarks,
log,
cancellationToken));
totalDuration =
TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks));
remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
if (remainingToFill > TimeSpan.Zero)
{

View File

@@ -167,7 +167,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
}
TimeSpan durationBlock = itemEndTimeWithFiller - itemStartTime;
if (itemEndTimeWithFiller - itemStartTime > scheduleItem.PlayoutDuration)
if (durationBlock > scheduleItem.PlayoutDuration)
{
Logger.LogWarning(
"Unable to schedule duration block of {DurationBlock:hh\\:mm\\:ss} which is longer than the configured playout duration {PlayoutDuration:hh\\:mm\\:ss}",

View File

@@ -15,7 +15,7 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
{
_mediaItems = mediaItems;
_lazyMinimumDuration =
new Lazy<Option<TimeSpan>>(() => _mediaItems.Bind(i => i.GetDuration()).OrderBy(identity).HeadOrNone());
new Lazy<Option<TimeSpan>>(() => _mediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
_random = new Random(state.Seed);
State = new CollectionEnumeratorState { Seed = state.Seed };

View File

@@ -15,7 +15,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
{
_sortedMediaItems = mediaItems.OrderBy(identity, new SeasonEpisodeMediaComparer()).ToList();
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(
() => _sortedMediaItems.Bind(i => i.GetDuration()).OrderBy(identity).HeadOrNone());
() => _sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };

View File

@@ -34,7 +34,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
_random = new Random(state.Seed);
_shuffled = Shuffle(_collections, _random);
_lazyMinimumDuration =
new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetDuration()).OrderBy(identity).HeadOrNone());
new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)

View File

@@ -31,7 +31,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
_random = new CloneableRandom(state.Seed);
_shuffled = Shuffle(_mediaItems, _random);
_lazyMinimumDuration =
new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetDuration()).OrderBy(identity).HeadOrNone());
new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)

View File

@@ -10,9 +10,9 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
<PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />

View File

@@ -51,7 +51,7 @@ public class PipelineBuilderBaseTests
640,
48,
Option<TimeSpan>.None,
false));
AudioFilter.None));
var desiredState = new FrameState(
true,
@@ -61,6 +61,7 @@ public class PipelineBuilderBaseTests
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
false,
Option<int>.None,
2000,
@@ -106,7 +107,7 @@ public class PipelineBuilderBaseTests
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:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -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 -readrate 1.0 -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:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
@@ -139,7 +140,7 @@ public class PipelineBuilderBaseTests
640,
48,
Option<TimeSpan>.None,
false));
AudioFilter.None));
var desiredState = new FrameState(
true,
@@ -149,6 +150,7 @@ public class PipelineBuilderBaseTests
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
false,
Option<int>.None,
2000,
@@ -194,7 +196,7 @@ public class PipelineBuilderBaseTests
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:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -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 -readrate 1.0 -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:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
@@ -220,7 +222,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(None, None, None, concatInputFile, result);
command.Should().Be(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -re -stream_loop -1 -i http://localhost:8080/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -readrate 1.0 -stream_loop -1 -i http://localhost:8080/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
@@ -248,7 +250,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(None, None, None, concatInputFile, result);
command.Should().Be(
"-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -i http://localhost:8080/iptv/channel/1.m3u8?mode=segmenter -map 0 -c copy -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts pipe:1");
"-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i http://localhost:8080/iptv/channel/1.m3u8?mode=segmenter -map 0 -c copy -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts pipe:1");
}
[Test]
@@ -281,7 +283,7 @@ public class PipelineBuilderBaseTests
None,
None,
None,
false));
AudioFilter.None));
var desiredState = new FrameState(
true,
@@ -291,6 +293,7 @@ public class PipelineBuilderBaseTests
Option<IPixelFormat>.None,
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
false,
Option<int>.None,
2000,
@@ -334,13 +337,13 @@ public class PipelineBuilderBaseTests
result.PipelineSteps.Should().HaveCountGreaterThan(0);
result.PipelineSteps.Should().Contain(ps => ps is EncoderCopyVideo);
result.PipelineSteps.Should().Contain(ps => ps is EncoderCopyAudio);
videoInputFile.InputOptions.Should().Contain(io => io is RealtimeInputOption);
videoInputFile.InputOptions.Should().Contain(io => io is ReadrateInputOption);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
// 0.4.0 reference: "-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -ss 00:14:33.6195516 -i /tmp/whatever.mkv -map 0:0 -map 0:a -c:v copy -flags cgop -sc_threshold 0 -c:a copy -movflags +faststart -muxdelay 0 -muxpreload 0 -metadata service_provider="ErsatzTV" -metadata service_name="ErsatzTV" -t 00:06:39.6934484 -f mpegts -mpegts_flags +initial_discontinuity pipe:1"
command.Should().Be(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -re -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
}
[Test]
@@ -373,6 +376,7 @@ public class PipelineBuilderBaseTests
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<FrameSize>.None,
false,
Option<int>.None,
2000,
@@ -420,7 +424,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
command.Should().Be(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -re -i /tmp/whatever.mkv -map 0:a -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:a -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1");
}
[Test]
@@ -476,7 +480,8 @@ public class PipelineBuilderBaseTests
audioInputFile,
watermarkInputFile,
concatInputFile,
pipeline.PipelineSteps);
pipeline.PipelineSteps,
pipeline.IsIntelVaapiOrQsv);
var command = string.Join(" ", arguments);
@@ -489,6 +494,8 @@ public class PipelineBuilderBaseTests
{
public DefaultFFmpegCapabilities()
: base(
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>())

View File

@@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg;
public enum AudioFilter
{
None = 0,
LoudNorm = 1,
DynAudNorm = 2
}

View File

@@ -5,25 +5,53 @@ namespace ErsatzTV.FFmpeg.Capabilities;
public class FFmpegCapabilities : IFFmpegCapabilities
{
private readonly IReadOnlySet<string> _ffmpegHardwareAccelerations;
private readonly IReadOnlySet<string> _ffmpegDecoders;
private readonly IReadOnlySet<string> _ffmpegEncoders;
private readonly IReadOnlySet<string> _ffmpegFilters;
private readonly IReadOnlySet<string> _ffmpegOptions;
public FFmpegCapabilities(
IReadOnlySet<string> ffmpegHardwareAccelerations,
IReadOnlySet<string> ffmpegDecoders,
IReadOnlySet<string> ffmpegFilters,
IReadOnlySet<string> ffmpegEncoders)
IReadOnlySet<string> ffmpegEncoders,
IReadOnlySet<string> ffmpegOptions)
{
_ffmpegHardwareAccelerations = ffmpegHardwareAccelerations;
_ffmpegDecoders = ffmpegDecoders;
_ffmpegFilters = ffmpegFilters;
_ffmpegEncoders = ffmpegEncoders;
_ffmpegOptions = ffmpegOptions;
}
public bool HasDecoder(string decoder) => _ffmpegDecoders.Contains(decoder);
public bool HasHardwareAcceleration(HardwareAccelerationMode hardwareAccelerationMode)
{
Option<FFmpegKnownHardwareAcceleration> maybeAccelToCheck = hardwareAccelerationMode switch
{
HardwareAccelerationMode.Amf => FFmpegKnownHardwareAcceleration.Amf,
HardwareAccelerationMode.Nvenc => FFmpegKnownHardwareAcceleration.Cuda,
HardwareAccelerationMode.Qsv => FFmpegKnownHardwareAcceleration.Qsv,
HardwareAccelerationMode.Vaapi => FFmpegKnownHardwareAcceleration.Vaapi,
HardwareAccelerationMode.VideoToolbox => FFmpegKnownHardwareAcceleration.VideoToolbox,
_ => Option<FFmpegKnownHardwareAcceleration>.None
};
public bool HasEncoder(string encoder) => _ffmpegEncoders.Contains(encoder);
foreach (FFmpegKnownHardwareAcceleration accelToCheck in maybeAccelToCheck)
{
return _ffmpegHardwareAccelerations.Contains(accelToCheck.Name);
}
public bool HasFilter(string filter) => _ffmpegFilters.Contains(filter);
return false;
}
public bool HasDecoder(FFmpegKnownDecoder decoder) => _ffmpegDecoders.Contains(decoder.Name);
public bool HasEncoder(FFmpegKnownEncoder encoder) => _ffmpegEncoders.Contains(encoder.Name);
public bool HasFilter(FFmpegKnownFilter filter) => _ffmpegFilters.Contains(filter.Name);
public bool HasOption(FFmpegKnownOption ffmpegOption) => _ffmpegOptions.Contains(ffmpegOption.Name);
public Option<IDecoder> SoftwareDecoderForVideoFormat(string videoFormat) =>
videoFormat switch

View File

@@ -0,0 +1,31 @@
namespace ErsatzTV.FFmpeg.Capabilities;
public record FFmpegKnownDecoder
{
public string Name { get; }
private FFmpegKnownDecoder(string Name)
{
this.Name = Name;
}
public static readonly FFmpegKnownDecoder Av1Cuvid = new("av1_cuvid");
public static readonly FFmpegKnownDecoder H264Cuvid = new("h264_cuvid");
public static readonly FFmpegKnownDecoder HevcCuvid = new("hevc_cuvid");
public static readonly FFmpegKnownDecoder Mpeg2Cuvid = new("mpeg2_cuvid");
public static readonly FFmpegKnownDecoder Mpeg4Cuvid = new("mpeg4_cuvid");
public static readonly FFmpegKnownDecoder Vc1Cuvid = new("vc1_cuvid");
public static readonly FFmpegKnownDecoder Vp9Cuvid = new("vp9_cuvid");
public static IList<string> AllDecoders =>
new[]
{
Av1Cuvid.Name,
H264Cuvid.Name,
HevcCuvid.Name,
Mpeg2Cuvid.Name,
Mpeg4Cuvid.Name,
Vc1Cuvid.Name,
Vp9Cuvid.Name
};
}

View File

@@ -0,0 +1,13 @@
namespace ErsatzTV.FFmpeg.Capabilities;
public record FFmpegKnownEncoder
{
public string Name { get; }
private FFmpegKnownEncoder(string Name)
{
this.Name = Name;
}
public static IList<string> AllEncoders => Array.Empty<string>();
}

View File

@@ -0,0 +1,19 @@
namespace ErsatzTV.FFmpeg.Capabilities;
public record FFmpegKnownFilter
{
public string Name { get; }
private FFmpegKnownFilter(string Name)
{
this.Name = Name;
}
public static readonly FFmpegKnownFilter ScaleNpp = new("scale_npp");
public static IList<string> AllFilters =>
new[]
{
ScaleNpp.Name
};
}

View File

@@ -0,0 +1,27 @@
namespace ErsatzTV.FFmpeg.Capabilities;
public record FFmpegKnownHardwareAcceleration
{
public string Name { get; }
private FFmpegKnownHardwareAcceleration(string Name)
{
this.Name = Name;
}
public static readonly FFmpegKnownHardwareAcceleration Amf = new("amf");
public static readonly FFmpegKnownHardwareAcceleration Cuda = new("cuda");
public static readonly FFmpegKnownHardwareAcceleration Qsv = new("qsv");
public static readonly FFmpegKnownHardwareAcceleration Vaapi = new("vaapi");
public static readonly FFmpegKnownHardwareAcceleration VideoToolbox = new("videotoolbox");
public static IList<string> AllAccels =>
new[]
{
Amf.Name,
Cuda.Name,
Qsv.Name,
Vaapi.Name,
VideoToolbox.Name
};
}

View File

@@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.FFmpeg.Capabilities;
[SuppressMessage("ReSharper", "IdentifierTypo")]
[SuppressMessage("ReSharper", "StringLiteralTypo")]
public record FFmpegKnownOption
{
public string Name { get; }
private FFmpegKnownOption(string Name)
{
this.Name = Name;
}
public static readonly FFmpegKnownOption ReadrateInitialBurst = new("readrate_initial_burst");
public static IList<string> AllOptions =>
new[]
{
ReadrateInitialBurst.Name
};
}

View File

@@ -1,10 +1,14 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.FFmpeg.Capabilities.Qsv;
using ErsatzTV.FFmpeg.Capabilities.Vaapi;
using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
using ErsatzTV.FFmpeg.Runtime;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -15,24 +19,50 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
private const string ArchitectureCacheKey = "ffmpeg.hardware.nvidia.architecture";
private const string ModelCacheKey = "ffmpeg.hardware.nvidia.model";
private const string VaapiCacheKeyFormat = "ffmpeg.hardware.vaapi.{0}.{1}";
private const string QsvCacheKeyFormat = "ffmpeg.hardware.qsv.{0}";
private const string FFmpegCapabilitiesCacheKeyFormat = "ffmpeg.{0}";
private readonly ILogger<HardwareCapabilitiesFactory> _logger;
private readonly IMemoryCache _memoryCache;
private readonly IRuntimeInfo _runtimeInfo;
public HardwareCapabilitiesFactory(IMemoryCache memoryCache, ILogger<HardwareCapabilitiesFactory> logger)
public HardwareCapabilitiesFactory(
IMemoryCache memoryCache,
IRuntimeInfo runtimeInfo,
ILogger<HardwareCapabilitiesFactory> logger)
{
_memoryCache = memoryCache;
_runtimeInfo = runtimeInfo;
_logger = logger;
}
public async Task<IFFmpegCapabilities> GetFFmpegCapabilities(string ffmpegPath)
{
IReadOnlySet<string> ffmpegDecoders = await GetFFmpegCapabilities(ffmpegPath, "decoders");
IReadOnlySet<string> ffmpegFilters = await GetFFmpegCapabilities(ffmpegPath, "filters");
IReadOnlySet<string> ffmpegEncoders = await GetFFmpegCapabilities(ffmpegPath, "encoders");
// TODO: validate videotoolbox somehow
// TODO: validate amf somehow
return new FFmpegCapabilities(ffmpegDecoders, ffmpegFilters, ffmpegEncoders);
IReadOnlySet<string> ffmpegHardwareAccelerations =
await GetFFmpegCapabilities(ffmpegPath, "hwaccels", ParseFFmpegAccelLine)
.Map(set => set.Intersect(FFmpegKnownHardwareAcceleration.AllAccels).ToImmutableHashSet());
IReadOnlySet<string> ffmpegDecoders = await GetFFmpegCapabilities(ffmpegPath, "decoders", ParseFFmpegLine)
.Map(set => set.Intersect(FFmpegKnownDecoder.AllDecoders).ToImmutableHashSet());
IReadOnlySet<string> ffmpegFilters = await GetFFmpegCapabilities(ffmpegPath, "filters", ParseFFmpegLine)
.Map(set => set.Intersect(FFmpegKnownFilter.AllFilters).ToImmutableHashSet());
IReadOnlySet<string> ffmpegEncoders = await GetFFmpegCapabilities(ffmpegPath, "encoders", ParseFFmpegLine)
.Map(set => set.Intersect(FFmpegKnownEncoder.AllEncoders).ToImmutableHashSet());
IReadOnlySet<string> ffmpegOptions = await GetFFmpegOptions(ffmpegPath)
.Map(set => set.Intersect(FFmpegKnownOption.AllOptions).ToImmutableHashSet());
return new FFmpegCapabilities(
ffmpegHardwareAccelerations,
ffmpegDecoders,
ffmpegFilters,
ffmpegEncoders,
ffmpegOptions);
}
public async Task<IHardwareCapabilities> GetHardwareCapabilities(
@@ -40,14 +70,31 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
string ffmpegPath,
HardwareAccelerationMode hardwareAccelerationMode,
Option<string> vaapiDriver,
Option<string> vaapiDevice) =>
hardwareAccelerationMode switch
Option<string> vaapiDevice)
{
if (hardwareAccelerationMode is HardwareAccelerationMode.None)
{
return new NoHardwareCapabilities();
}
if (!ffmpegCapabilities.HasHardwareAcceleration(hardwareAccelerationMode))
{
_logger.LogWarning(
"FFmpeg does not support {HardwareAcceleration} acceleration; will use software mode",
hardwareAccelerationMode);
return new NoHardwareCapabilities();
}
return hardwareAccelerationMode switch
{
HardwareAccelerationMode.Nvenc => await GetNvidiaCapabilities(ffmpegPath, ffmpegCapabilities),
HardwareAccelerationMode.Qsv => await GetQsvCapabilities(ffmpegPath, vaapiDevice),
HardwareAccelerationMode.Vaapi => await GetVaapiCapabilities(vaapiDriver, vaapiDevice),
HardwareAccelerationMode.Amf => new AmfHardwareCapabilities(),
_ => new DefaultHardwareCapabilities()
};
}
public async Task<string> GetNvidiaOutput(string ffmpegPath)
{
@@ -71,6 +118,33 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
return output;
}
public async Task<QsvOutput> GetQsvOutput(string ffmpegPath, Option<string> qsvDevice)
{
var option = new QsvHardwareAccelerationOption(qsvDevice);
var arguments = option.GlobalOptions.ToList();
arguments.AddRange(
new[]
{
"-f", "lavfi",
"-i", "nullsrc",
"-t", "00:00:01",
"-c:v", "h264_qsv",
"-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;
return new QsvOutput(result.ExitCode, output);
}
public async Task<Option<string>> GetVaapiOutput(Option<string> vaapiDriver, string vaapiDevice)
{
@@ -99,13 +173,16 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
return result.StandardOutput;
}
private async Task<IReadOnlySet<string>> GetFFmpegCapabilities(string ffmpegPath, string capabilities)
private async Task<IReadOnlySet<string>> GetFFmpegCapabilities(
string ffmpegPath,
string capabilities,
Func<string, Option<string>> parseLine)
{
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, capabilities);
if (_memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? cachedDecoders) &&
cachedDecoders is not null)
if (_memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? cachedCapabilities) &&
cachedCapabilities is not null)
{
return cachedDecoders;
return cachedCapabilities;
}
string[] arguments = { "-hide_banner", $"-{capabilities}" };
@@ -120,9 +197,41 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
: result.StandardOutput;
return output.Split("\n").Map(s => s.Trim())
.Bind(l => ParseFFmpegLine(l))
.Bind(l => parseLine(l))
.ToImmutableHashSet();
}
private async Task<IReadOnlySet<string>> GetFFmpegOptions(string ffmpegPath)
{
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "options");
if (_memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? cachedCapabilities) &&
cachedCapabilities is not null)
{
return cachedCapabilities;
}
string[] arguments = { "-hide_banner", "-h", "long" };
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8);
string output = string.IsNullOrWhiteSpace(result.StandardOutput)
? result.StandardError
: result.StandardOutput;
return output.Split("\n").Map(s => s.Trim())
.Bind(l => ParseFFmpegOptionLine(l))
.ToImmutableHashSet();
}
private static Option<string> ParseFFmpegAccelLine(string input)
{
const string PATTERN = @"^([\w]+)$";
Match match = Regex.Match(input, PATTERN);
return match.Success ? match.Groups[1].Value : Option<string>.None;
}
private static Option<string> ParseFFmpegLine(string input)
{
@@ -131,6 +240,13 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
return match.Success ? match.Groups[1].Value : Option<string>.None;
}
private static Option<string> ParseFFmpegOptionLine(string input)
{
const string PATTERN = @"^-([a-z_]+)\s+.*";
Match match = Regex.Match(input, PATTERN);
return match.Success ? match.Groups[1].Value : Option<string>.None;
}
private async Task<IHardwareCapabilities> GetVaapiCapabilities(
Option<string> vaapiDriver,
Option<string> vaapiDevice)
@@ -195,6 +311,71 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
return new NoHardwareCapabilities();
}
private async Task<IHardwareCapabilities> GetQsvCapabilities(string ffmpegPath, Option<string> qsvDevice)
{
try
{
if (_runtimeInfo.IsOSPlatform(OSPlatform.Linux) && qsvDevice.IsNone)
{
// this shouldn't really happen
_logger.LogError("Cannot detect QSV capabilities without device {Device}", qsvDevice);
return new NoHardwareCapabilities();
}
string device = qsvDevice.IfNone(string.Empty);
var cacheKey = string.Format(CultureInfo.InvariantCulture, QsvCacheKeyFormat, device);
if (_memoryCache.TryGetValue(cacheKey, out List<VaapiProfileEntrypoint>? profileEntrypoints) &&
profileEntrypoints is not null)
{
return new VaapiHardwareCapabilities(profileEntrypoints, _logger);
}
QsvOutput output = await GetQsvOutput(ffmpegPath, qsvDevice);
if (output.ExitCode != 0)
{
_logger.LogWarning("QSV test failed; some hardware accelerated features will be unavailable");
return new NoHardwareCapabilities();
}
if (_runtimeInfo.IsOSPlatform(OSPlatform.Linux))
{
Option<string> vaapiOutput = await GetVaapiOutput(Option<string>.None, device);
if (vaapiOutput.IsNone)
{
_logger.LogWarning("Unable to determine QSV capabilities; please install vainfo");
return new DefaultHardwareCapabilities();
}
foreach (string o in vaapiOutput)
{
profileEntrypoints = VaapiCapabilityParser.ParseFull(o);
}
if (profileEntrypoints?.Any() ?? false)
{
_logger.LogInformation(
"Detected {Count} VAAPI profile entrypoints for using QSV device {Device}",
profileEntrypoints.Count,
device);
_memoryCache.Set(cacheKey, profileEntrypoints);
return new VaapiHardwareCapabilities(profileEntrypoints, _logger);
}
}
// not sure how to check capabilities on windows
return new DefaultHardwareCapabilities();
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Error detecting QSV capabilities; some hardware accelerated features will be unavailable");
return new NoHardwareCapabilities();
}
}
private async Task<IHardwareCapabilities> GetNvidiaCapabilities(
string ffmpegPath,
IFFmpegCapabilities ffmpegCapabilities)

View File

@@ -4,8 +4,10 @@ namespace ErsatzTV.FFmpeg.Capabilities;
public interface IFFmpegCapabilities
{
bool HasDecoder(string decoder);
bool HasEncoder(string encoder);
bool HasFilter(string filter);
bool HasHardwareAcceleration(HardwareAccelerationMode hardwareAccelerationMode);
bool HasDecoder(FFmpegKnownDecoder decoder);
bool HasEncoder(FFmpegKnownEncoder encoder);
bool HasFilter(FFmpegKnownFilter filter);
bool HasOption(FFmpegKnownOption ffmpegOption);
Option<IDecoder> SoftwareDecoderForVideoFormat(string videoFormat);
}

View File

@@ -1,3 +1,5 @@
using ErsatzTV.FFmpeg.Capabilities.Qsv;
namespace ErsatzTV.FFmpeg.Capabilities;
public interface IHardwareCapabilitiesFactory
@@ -13,5 +15,7 @@ public interface IHardwareCapabilitiesFactory
Task<string> GetNvidiaOutput(string ffmpegPath);
Task<QsvOutput> GetQsvOutput(string ffmpegPath, Option<string> qsvDevice);
Task<Option<string>> GetVaapiOutput(Option<string> vaapiDriver, string vaapiDevice);
}

View File

@@ -67,13 +67,15 @@ public class NvidiaHardwareCapabilities : IHardwareCapabilities
{
return videoFormat switch
{
VideoFormat.Mpeg2Video => CheckHardwareCodec("mpeg2_cuvid", _ffmpegCapabilities.HasDecoder),
VideoFormat.Mpeg4 => CheckHardwareCodec("mpeg4_cuvid", _ffmpegCapabilities.HasDecoder),
VideoFormat.Vc1 => CheckHardwareCodec("vc1_cuvid", _ffmpegCapabilities.HasDecoder),
VideoFormat.H264 => CheckHardwareCodec("h264_cuvid", _ffmpegCapabilities.HasDecoder),
VideoFormat.Hevc => CheckHardwareCodec("hevc_cuvid", _ffmpegCapabilities.HasDecoder),
VideoFormat.Vp9 => CheckHardwareCodec("hevc_cuvid", _ffmpegCapabilities.HasDecoder),
VideoFormat.Av1 => CheckHardwareCodec("av1_cuvid", _ffmpegCapabilities.HasDecoder),
VideoFormat.Mpeg2Video => CheckHardwareCodec(
FFmpegKnownDecoder.Mpeg2Cuvid,
_ffmpegCapabilities.HasDecoder),
VideoFormat.Mpeg4 => CheckHardwareCodec(FFmpegKnownDecoder.Mpeg4Cuvid, _ffmpegCapabilities.HasDecoder),
VideoFormat.Vc1 => CheckHardwareCodec(FFmpegKnownDecoder.Vc1Cuvid, _ffmpegCapabilities.HasDecoder),
VideoFormat.H264 => CheckHardwareCodec(FFmpegKnownDecoder.H264Cuvid, _ffmpegCapabilities.HasDecoder),
VideoFormat.Hevc => CheckHardwareCodec(FFmpegKnownDecoder.HevcCuvid, _ffmpegCapabilities.HasDecoder),
VideoFormat.Vp9 => CheckHardwareCodec(FFmpegKnownDecoder.Vp9Cuvid, _ffmpegCapabilities.HasDecoder),
VideoFormat.Av1 => CheckHardwareCodec(FFmpegKnownDecoder.Av1Cuvid, _ffmpegCapabilities.HasDecoder),
_ => FFmpegCapability.Software
};
}
@@ -108,14 +110,14 @@ public class NvidiaHardwareCapabilities : IHardwareCapabilities
public Option<RateControlMode> GetRateControlMode(string videoFormat, Option<IPixelFormat> maybePixelFormat) =>
Option<RateControlMode>.None;
private FFmpegCapability CheckHardwareCodec(string codec, Func<string, bool> check)
private FFmpegCapability CheckHardwareCodec(FFmpegKnownDecoder codec, Func<FFmpegKnownDecoder, bool> check)
{
if (check(codec))
{
return FFmpegCapability.Hardware;
}
_logger.LogWarning("FFmpeg does not contain codec {Codec}; will fall back to software codec", codec);
_logger.LogWarning("FFmpeg does not contain codec {Codec}; will fall back to software codec", codec.Name);
return FFmpegCapability.Software;
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.FFmpeg.Capabilities.Qsv;
public record QsvOutput(int ExitCode, string Output);

View File

@@ -2,7 +2,6 @@
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Filter;
using ErsatzTV.FFmpeg.GlobalOption;
using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
using ErsatzTV.FFmpeg.InputOption;
namespace ErsatzTV.FFmpeg;
@@ -17,7 +16,8 @@ public static class CommandGenerator
Option<AudioInputFile> maybeAudioInputFile,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<ConcatInputFile> maybeConcatInputFile,
IList<IPipelineStep> pipelineSteps)
IList<IPipelineStep> pipelineSteps,
bool isIntelVaapiOrQsv)
{
var arguments = new List<string>();
@@ -41,10 +41,7 @@ public static class CommandGenerator
foreach (AudioInputFile audioInputFile in maybeAudioInputFile)
{
bool isVaapiOrQsv =
pipelineSteps.Any(s => s is VaapiHardwareAccelerationOption or QsvHardwareAccelerationOption);
if (!includedPaths.Contains(audioInputFile.Path) || isVaapiOrQsv)
if (!includedPaths.Contains(audioInputFile.Path) || isIntelVaapiOrQsv)
{
includedPaths.Add(audioInputFile.Path);

View File

@@ -0,0 +1,13 @@
namespace ErsatzTV.FFmpeg.Decoder.Cuvid;
public class DecoderImplicitCuda : DecoderBase
{
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
public override string Name => string.Empty;
public override IList<string> InputOptions(InputFile inputFile) =>
new List<string>
{
"-hwaccel_output_format",
"cuda"
};
}

View File

@@ -19,7 +19,7 @@ public class EncoderHevcNvenc : EncoderBase
public override StreamKind Kind => StreamKind.Video;
public override IList<string> OutputOptions =>
new[] { "-c:v", "hevc_nvenc", "-b_ref_mode", _bFrames ? "1" : "0" };
new[] { "-c:v", "hevc_nvenc", "-tag:v", "hvc1", "-b_ref_mode", _bFrames ? "1" : "0" };
public override FrameState NextState(FrameState currentState) => currentState with
{

View File

@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.6.4" />
<PackageReference Include="LanguageExt.Core" Version="4.4.4" />
<PackageReference Include="LanguageExt.Core" Version="4.4.7" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
</ItemGroup>

View File

@@ -1,3 +1,3 @@
namespace ErsatzTV.FFmpeg;
public record FFmpegPipeline(IList<IPipelineStep> PipelineSteps);
public record FFmpegPipeline(IList<IPipelineStep> PipelineSteps, bool IsIntelVaapiOrQsv);

View File

@@ -73,9 +73,8 @@ public class ComplexFilter : IPipelineStep
foreach ((string path, _) in _maybeAudioInputFile)
{
if (!distinctPaths.Contains(path) ||
// use audio as a separate input with vaapi/qsv
_pipelineContext.HardwareAccelerationMode is HardwareAccelerationMode.Vaapi
or HardwareAccelerationMode.Qsv)
// use audio as a separate input with intel vaapi/qsv
_pipelineContext.IsIntelVaapiOrQsv)
{
distinctPaths.Add(path);
}

View File

@@ -0,0 +1,49 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Filter;
public class CropFilter : BaseFilter
{
private readonly FrameState _currentState;
private readonly FrameSize _croppedSize;
public CropFilter(FrameState currentState, FrameSize croppedSize)
{
_currentState = currentState;
_croppedSize = croppedSize;
}
public override string Filter
{
get
{
var crop = $"crop=w={_croppedSize.Width}:h={_croppedSize.Height}";
if (_currentState.FrameDataLocation == FrameDataLocation.Hardware)
{
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
{
if (pixelFormat is PixelFormatVaapi)
{
foreach (IPixelFormat pf in AvailablePixelFormats.ForPixelFormat(pixelFormat.Name, null))
{
return $"hwdownload,format=vaapi|{pf.FFmpegName},{crop}";
}
}
return $"hwdownload,format={pixelFormat.FFmpegName},{crop}";
}
return $"hwdownload,{crop}";
}
return crop;
}
}
public override FrameState NextState(FrameState currentState) => currentState with
{
PaddedSize = _croppedSize,
FrameDataLocation = FrameDataLocation.Software
};
}

View File

@@ -7,20 +7,25 @@ public class ScaleCudaFilter : BaseFilter
private readonly FrameState _currentState;
private readonly bool _isAnamorphicEdgeCase;
private readonly FrameSize _paddedSize;
private readonly Option<FrameSize> _croppedSize;
private readonly FrameSize _scaledSize;
public ScaleCudaFilter(
FrameState currentState,
FrameSize scaledSize,
FrameSize paddedSize,
Option<FrameSize> croppedSize,
bool isAnamorphicEdgeCase)
{
_currentState = currentState;
_scaledSize = scaledSize;
_paddedSize = paddedSize;
_croppedSize = croppedSize;
_isAnamorphicEdgeCase = isAnamorphicEdgeCase;
}
public bool IsFormatOnly => _currentState.ScaledSize == _scaledSize;
public override string Filter
{
get
@@ -39,7 +44,9 @@ public class ScaleCudaFilter : BaseFilter
string aspectRatio = string.Empty;
if (_scaledSize != _paddedSize)
{
aspectRatio = ":force_original_aspect_ratio=decrease";
aspectRatio = _croppedSize.IsSome
? ":force_original_aspect_ratio=increase"
: ":force_original_aspect_ratio=decrease";
}
string squareScale = string.Empty;

View File

@@ -2,7 +2,19 @@
public class NormalizeLoudnessFilter : BaseFilter
{
public override string Filter => "loudnorm=I=-16:TP=-1.5:LRA=11";
private readonly AudioFilter _loudnessFilter;
public NormalizeLoudnessFilter(AudioFilter loudnessFilter)
{
_loudnessFilter = loudnessFilter;
}
public override string Filter => _loudnessFilter switch
{
AudioFilter.LoudNorm => "loudnorm=I=-16:TP=-1.5:LRA=11",
AudioFilter.DynAudNorm => "dynaudnorm",
_ => string.Empty
};
public override FrameState NextState(FrameState currentState) => currentState;
}

View File

@@ -7,13 +7,20 @@ public class ScaleFilter : BaseFilter
private readonly FrameState _currentState;
private readonly bool _isAnamorphicEdgeCase;
private readonly FrameSize _paddedSize;
private readonly Option<FrameSize> _croppedSize;
private readonly FrameSize _scaledSize;
public ScaleFilter(FrameState currentState, FrameSize scaledSize, FrameSize paddedSize, bool isAnamorphicEdgeCase)
public ScaleFilter(
FrameState currentState,
FrameSize scaledSize,
FrameSize paddedSize,
Option<FrameSize> croppedSize,
bool isAnamorphicEdgeCase)
{
_currentState = currentState;
_scaledSize = scaledSize;
_paddedSize = paddedSize;
_croppedSize = croppedSize;
_isAnamorphicEdgeCase = isAnamorphicEdgeCase;
}
@@ -29,7 +36,9 @@ public class ScaleFilter : BaseFilter
string aspectRatio = string.Empty;
if (_scaledSize != _paddedSize)
{
aspectRatio = ":force_original_aspect_ratio=decrease";
aspectRatio = _croppedSize.IsSome
? ":force_original_aspect_ratio=increase"
: ":force_original_aspect_ratio=decrease";
}
string scale;

View File

@@ -7,17 +7,20 @@ public class ScaleVaapiFilter : BaseFilter
private readonly FrameState _currentState;
private readonly bool _isAnamorphicEdgeCase;
private readonly FrameSize _paddedSize;
private readonly Option<FrameSize> _croppedSize;
private readonly FrameSize _scaledSize;
public ScaleVaapiFilter(
FrameState currentState,
FrameSize scaledSize,
FrameSize paddedSize,
Option<FrameSize> croppedSize,
bool isAnamorphicEdgeCase)
{
_currentState = currentState;
_scaledSize = scaledSize;
_paddedSize = paddedSize;
_croppedSize = croppedSize;
_isAnamorphicEdgeCase = isAnamorphicEdgeCase;
}
@@ -40,7 +43,9 @@ public class ScaleVaapiFilter : BaseFilter
string aspectRatio = string.Empty;
if (_scaledSize != _paddedSize)
{
aspectRatio = ":force_original_aspect_ratio=decrease";
aspectRatio = _croppedSize.IsSome
? ":force_original_aspect_ratio=increase"
: ":force_original_aspect_ratio=decrease";
}
string squareScale = string.Empty;

View File

@@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Filter.Vaapi;
public class VaapiSubtitlePixelFormatFilter : BaseFilter
{
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter => "format=vaapi|yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8";
}

View File

@@ -10,6 +10,7 @@ public record FrameState(
Option<IPixelFormat> PixelFormat,
FrameSize ScaledSize,
FrameSize PaddedSize,
Option<FrameSize> CroppedSize,
bool IsAnamorphic,
Option<int> FrameRate,
Option<int> VideoBitrate,

View File

@@ -0,0 +1,64 @@
using System.Globalization;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Environment;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.FFmpeg.InputOption;
public class ReadrateInputOption : IInputOption
{
private readonly IFFmpegCapabilities _ffmpegCapabilities;
private readonly int _initialBurstSeconds;
private readonly ILogger _logger;
public ReadrateInputOption(
IFFmpegCapabilities ffmpegCapabilities,
int initialBurstSeconds,
ILogger logger)
{
_ffmpegCapabilities = ffmpegCapabilities;
_initialBurstSeconds = initialBurstSeconds;
_logger = logger;
}
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile)
{
var result = new List<string> { "-readrate", "1.0" };
if (_initialBurstSeconds > 0)
{
if (!_ffmpegCapabilities.HasOption(FFmpegKnownOption.ReadrateInitialBurst))
{
_logger.LogWarning(
"FFmpeg is missing {Option} option; unable to transcode faster than realtime",
FFmpegKnownOption.ReadrateInitialBurst.Name);
return result;
}
result.AddRange(
new[]
{
"-readrate_initial_burst",
_initialBurstSeconds.ToString(CultureInfo.InvariantCulture)
});
}
return result;
}
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState with { Realtime = true };
public bool AppliesTo(AudioInputFile audioInputFile) => true;
// don't use realtime input for a still image
public bool AppliesTo(VideoInputFile videoInputFile) => videoInputFile.VideoStreams.All(s => !s.StillImage);
public bool AppliesTo(ConcatInputFile concatInputFile) => true;
}

View File

@@ -1,23 +0,0 @@
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.InputOption;
public class RealtimeInputOption : IInputOption
{
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => new List<string> { "-re" };
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState with { Realtime = true };
public bool AppliesTo(AudioInputFile audioInputFile) => true;
// don't use realtime input for a still image
public bool AppliesTo(VideoInputFile videoInputFile) => videoInputFile.VideoStreams.All(s => !s.StillImage);
public bool AppliesTo(ConcatInputFile concatInputFile) => true;
}

View File

@@ -120,6 +120,36 @@ public record VideoStream(
return result;
}
public FrameSize SquarePixelFrameSizeForCrop(FrameSize resolution)
{
int width = FrameSize.Width;
int height = FrameSize.Height;
if (IsAnamorphic)
{
double sar = GetSAR();
bool edgeCase = IsAnamorphicEdgeCase;
width = edgeCase
? FrameSize.Width
: (int)Math.Floor(FrameSize.Width * sar);
height = edgeCase
? (int)Math.Floor(FrameSize.Height * sar)
: FrameSize.Height;
}
double widthPercent = (double)resolution.Width / width;
double heightPercent = (double)resolution.Height / height;
double maxPercent = Math.Max(widthPercent, heightPercent);
var result = new FrameSize(
(int)Math.Floor(width * maxPercent),
(int)Math.Floor(height * maxPercent));
return result;
}
private double GetSAR()
{

View File

@@ -7,18 +7,21 @@ public class OutputFormatHls : IPipelineStep
private readonly FrameState _desiredState;
private readonly Option<string> _mediaFrameRate;
private readonly string _playlistPath;
private readonly bool _oneSecondGop;
private readonly string _segmentTemplate;
public OutputFormatHls(
FrameState desiredState,
Option<string> mediaFrameRate,
string segmentTemplate,
string playlistPath)
string playlistPath,
bool oneSecondGop = false)
{
_desiredState = desiredState;
_mediaFrameRate = mediaFrameRate;
_segmentTemplate = segmentTemplate;
_playlistPath = playlistPath;
_oneSecondGop = oneSecondGop;
}
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
@@ -33,9 +36,11 @@ public class OutputFormatHls : IPipelineStep
const int SEGMENT_SECONDS = 4;
int frameRate = _desiredState.FrameRate.IfNone(GetFrameRateFromMedia);
int gop = _oneSecondGop ? frameRate : frameRate * SEGMENT_SECONDS;
return new List<string>
{
"-g", $"{frameRate * SEGMENT_SECONDS}",
"-g", $"{gop}",
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
"-f", "hls",

View File

@@ -0,0 +1,6 @@
namespace ErsatzTV.FFmpeg.OutputOption;
public class NoAutoScaleOutputOption : OutputOption
{
public override IList<string> OutputOptions => new List<string> { "-noautoscale" };
}

View File

@@ -16,6 +16,7 @@ namespace ErsatzTV.FFmpeg.Pipeline;
public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
{
private readonly IFFmpegCapabilities _ffmpegCapabilities;
private readonly IHardwareCapabilities _hardwareCapabilities;
private readonly ILogger _logger;
@@ -40,6 +41,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
fontsFolder,
logger)
{
_ffmpegCapabilities = ffmpegCapabilities;
_hardwareCapabilities = hardwareCapabilities;
_logger = logger;
}
@@ -182,6 +184,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
currentState with { PixelFormat = Some(pixelFormat) },
currentState.ScaledSize,
currentState.PaddedSize,
Option<FrameSize>.None,
false);
currentState = filter.NextState(currentState);
videoInputFile.FilterSteps.Add(filter);
@@ -416,7 +419,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
return currentState;
}
private static FrameState SetSubtitle(
private FrameState SetSubtitle(
VideoInputFile videoInputFile,
Option<SubtitleInputFile> subtitleInputFile,
PipelineContext context,
@@ -465,17 +468,33 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
if (currentState.PixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 8)
{
var subtitleHardwareUpload = new HardwareUploadCudaFilter(
currentState with { FrameDataLocation = FrameDataLocation.Software });
subtitle.FilterSteps.Add(subtitleHardwareUpload);
// only scale if scaling or padding was used for main video stream
if (videoInputFile.FilterSteps.Any(s => s is ScaleFilter or ScaleCudaFilter or PadFilter))
if (_ffmpegCapabilities.HasFilter(FFmpegKnownFilter.ScaleNpp))
{
var scaleFilter = new SubtitleScaleNppFilter(desiredState.PaddedSize);
subtitle.FilterSteps.Add(scaleFilter);
}
var subtitleHardwareUpload = new HardwareUploadCudaFilter(
currentState with { FrameDataLocation = FrameDataLocation.Software });
subtitle.FilterSteps.Add(subtitleHardwareUpload);
// only scale if scaling or padding was used for main video stream
if (videoInputFile.FilterSteps.Any(s => s is ScaleFilter or ScaleCudaFilter { IsFormatOnly: false } or PadFilter))
{
var scaleFilter = new SubtitleScaleNppFilter(desiredState.PaddedSize);
subtitle.FilterSteps.Add(scaleFilter);
}
}
else
{
// only scale if scaling or padding was used for main video stream
if (videoInputFile.FilterSteps.Any(s => s is ScaleFilter or ScaleCudaFilter { IsFormatOnly: false } or PadFilter))
{
var scaleFilter = new ScaleImageFilter(desiredState.PaddedSize);
subtitle.FilterSteps.Add(scaleFilter);
}
var subtitleHardwareUpload = new HardwareUploadCudaFilter(
currentState with { FrameDataLocation = FrameDataLocation.Software });
subtitle.FilterSteps.Add(subtitleHardwareUpload);
}
var subtitlesFilter = new OverlaySubtitleCudaFilter();
subtitleOverlayFilterSteps.Add(subtitlesFilter);
}
@@ -517,7 +536,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
FrameState desiredState,
FrameState currentState)
{
if (currentState.PaddedSize != desiredState.PaddedSize)
if (desiredState.CroppedSize.IsNone && currentState.PaddedSize != desiredState.PaddedSize)
{
IPipelineFilterStep padStep = new PadFilter(currentState, desiredState.PaddedSize);
currentState = padStep.NextState(currentState);
@@ -555,6 +574,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
currentState,
desiredState.ScaledSize,
desiredState.PaddedSize,
desiredState.CroppedSize,
VideoStream.IsAnamorphicEdgeCase);
}
else
@@ -579,6 +599,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
},
desiredState.ScaledSize,
desiredState.PaddedSize,
desiredState.CroppedSize,
VideoStream.IsAnamorphicEdgeCase);
}

View File

@@ -66,7 +66,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
pipelineSteps.Add(scaleStep);
pipelineSteps.Add(new FileNameOutputOption(outputFile));
return new FFmpegPipeline(pipelineSteps);
return new FFmpegPipeline(pipelineSteps, false);
}
public FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState)
@@ -84,7 +84,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
};
concatInputFile.AddOption(new ConcatInputFormat());
concatInputFile.AddOption(new RealtimeInputOption());
concatInputFile.AddOption(new ReadrateInputOption(_ffmpegCapabilities, 0, _logger));
concatInputFile.AddOption(new InfiniteLoopInputOption(HardwareAccelerationMode.None));
foreach (int threadCount in ffmpegState.ThreadCount)
@@ -113,7 +113,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
pipelineSteps.Add(new FFReportVariable(_reportsFolder, concatInputFile));
}
return new FFmpegPipeline(pipelineSteps);
return new FFmpegPipeline(pipelineSteps, false);
}
public FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState)
@@ -130,7 +130,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
new EncoderCopyAll()
};
concatInputFile.AddOption(new RealtimeInputOption());
concatInputFile.AddOption(new ReadrateInputOption(_ffmpegCapabilities, 0, _logger));
SetMetadataServiceProvider(ffmpegState, pipelineSteps);
SetMetadataServiceName(ffmpegState, pipelineSteps);
@@ -138,7 +138,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
pipelineSteps.Add(new OutputFormatMpegTs(false));
pipelineSteps.Add(new PipeProtocol());
return new FFmpegPipeline(pipelineSteps);
return new FFmpegPipeline(pipelineSteps, false);
}
public FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState)
@@ -174,7 +174,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
_subtitleInputFile.Map(s => s is { IsImageBased: true, Method: SubtitleMethod.Burn }).IfNone(false),
_subtitleInputFile.Map(s => s is { IsImageBased: false, Method: SubtitleMethod.Burn }).IfNone(false),
desiredState.Deinterlaced,
desiredState.PixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 10);
desiredState.PixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 10,
false);
SetThreadCount(ffmpegState, desiredState, pipelineSteps);
SetSceneDetect(videoStream, ffmpegState, desiredState, pipelineSteps);
@@ -190,6 +191,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
context,
pipelineSteps);
context = context with { IsIntelVaapiOrQsv = IsIntelVaapiOrQsv(ffmpegState) };
if (_audioInputFile.IsNone)
{
pipelineSteps.Add(new EncoderCopyAudio());
@@ -219,7 +222,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
pipelineSteps.Add(complexFilter);
return new FFmpegPipeline(pipelineSteps);
return new FFmpegPipeline(pipelineSteps, context.IsIntelVaapiOrQsv);
}
private void LogUnknownDecoder(
@@ -271,7 +274,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
desiredState,
videoStream.FrameRate,
segmentTemplate,
playlistPath));
playlistPath,
ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv));
}
}
@@ -350,9 +354,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
private void SetAudioLoudness(AudioInputFile audioInputFile)
{
if (audioInputFile.DesiredState.NormalizeLoudness)
if (audioInputFile.DesiredState.NormalizeLoudnessFilter is not AudioFilter.None)
{
_audioInputFile.Iter(f => f.FilterSteps.Add(new NormalizeLoudnessFilter()));
_audioInputFile.Iter(
f => f.FilterSteps.Add(
new NormalizeLoudnessFilter(audioInputFile.DesiredState.NormalizeLoudnessFilter)));
}
}
@@ -395,6 +401,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
}
}
protected abstract bool IsIntelVaapiOrQsv(FFmpegState ffmpegState);
protected abstract FFmpegState SetAccelState(
VideoStream videoStream,
FFmpegState ffmpegState,
@@ -433,7 +441,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
: SetDecoder(videoInputFile, videoStream, ffmpegState, context);
SetStillImageInfiniteLoop(videoInputFile, videoStream, ffmpegState);
SetRealtimeInput(videoInputFile, desiredState);
SetRealtimeInput(videoInputFile, ffmpegState, desiredState);
SetInfiniteLoop(videoInputFile, videoStream, ffmpegState, desiredState);
SetFrameRateOutput(desiredState, pipelineSteps);
SetVideoTrackTimescaleOutput(desiredState, pipelineSteps);
@@ -502,6 +510,21 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
FrameState desiredState,
string fontsFolder,
ICollection<IPipelineStep> pipelineSteps);
protected static FrameState SetCrop(
VideoInputFile videoInputFile,
FrameState desiredState,
FrameState currentState)
{
foreach (FrameSize croppedSize in currentState.CroppedSize)
{
IPipelineFilterStep cropStep = new CropFilter(currentState, croppedSize);
currentState = cropStep.NextState(currentState);
videoInputFile.FilterSteps.Add(cropStep);
}
return currentState;
}
private static void SetOutputTsOffset(
FFmpegState ffmpegState,
@@ -596,13 +619,29 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
}
}
private void SetRealtimeInput(VideoInputFile videoInputFile, FrameState desiredState)
private void SetRealtimeInput(VideoInputFile videoInputFile, FFmpegState ffmpegState, FrameState desiredState)
{
if (desiredState.Realtime)
int initialBurst;
if (!desiredState.Realtime)
{
_audioInputFile.Iter(a => a.AddOption(new RealtimeInputOption()));
videoInputFile.AddOption(new RealtimeInputOption());
initialBurst = 180;
}
else
{
AudioFilter filter = _audioInputFile
.Map(a => a.DesiredState.NormalizeLoudnessFilter)
.IfNone(AudioFilter.None);
initialBurst = filter switch
{
AudioFilter.LoudNorm => 5,
AudioFilter.DynAudNorm => 15,
_ => 0
};
}
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(_ffmpegCapabilities, initialBurst, _logger)));
videoInputFile.AddOption(new ReadrateInputOption(_ffmpegCapabilities, initialBurst, _logger));
}
private static void SetStillImageInfiniteLoop(

View File

@@ -1,5 +1,4 @@
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Runtime;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.FFmpeg.Pipeline;
@@ -8,14 +7,11 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
{
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
private readonly ILogger<PipelineBuilderFactory> _logger;
private readonly IRuntimeInfo _runtimeInfo;
public PipelineBuilderFactory(
IRuntimeInfo runtimeInfo,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
ILogger<PipelineBuilderFactory> logger)
{
_runtimeInfo = runtimeInfo;
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
_logger = logger;
}
@@ -65,7 +61,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
reportsFolder,
fontsFolder,
_logger),
HardwareAccelerationMode.Qsv => new QsvPipelineBuilder(
HardwareAccelerationMode.Qsv when capabilities is not NoHardwareCapabilities => new QsvPipelineBuilder(
ffmpegCapabilities,
capabilities,
hardwareAccelerationMode,
@@ -76,7 +72,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
reportsFolder,
fontsFolder,
_logger),
HardwareAccelerationMode.VideoToolbox => new VideoToolboxPipelineBuilder(
HardwareAccelerationMode.VideoToolbox when capabilities is not NoHardwareCapabilities => new VideoToolboxPipelineBuilder(
ffmpegCapabilities,
capabilities,
hardwareAccelerationMode,
@@ -87,7 +83,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
reportsFolder,
fontsFolder,
_logger),
HardwareAccelerationMode.Amf => new AmfPipelineBuilder(
HardwareAccelerationMode.Amf when capabilities is not NoHardwareCapabilities => new AmfPipelineBuilder(
ffmpegCapabilities,
capabilities,
hardwareAccelerationMode,

View File

@@ -6,4 +6,5 @@ public record PipelineContext(
bool HasSubtitleOverlay,
bool HasSubtitleText,
bool ShouldDeinterlace,
bool Is10BitOutput);
bool Is10BitOutput,
bool IsIntelVaapiOrQsv);

View File

@@ -44,6 +44,10 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
_logger = logger;
}
protected override bool IsIntelVaapiOrQsv(FFmpegState ffmpegState) =>
ffmpegState.DecoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv ||
ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv;
protected override FFmpegState SetAccelState(
VideoStream videoStream,
FFmpegState ffmpegState,
@@ -521,7 +525,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
FrameState desiredState,
FrameState currentState)
{
if (currentState.PaddedSize != desiredState.PaddedSize)
if (desiredState.CroppedSize.IsNone && currentState.PaddedSize != desiredState.PaddedSize)
{
IPipelineFilterStep padStep = new PadFilter(
currentState,
@@ -553,6 +557,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
currentState,
desiredState.ScaledSize,
desiredState.PaddedSize,
desiredState.CroppedSize,
VideoStream.IsAnamorphicEdgeCase);
}
else

View File

@@ -35,6 +35,8 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
logger) =>
_logger = logger;
protected override bool IsIntelVaapiOrQsv(FFmpegState ffmpegState) => false;
protected override FFmpegState SetAccelState(
VideoStream videoStream,
FFmpegState ffmpegState,
@@ -102,6 +104,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
currentState = SetScale(videoInputFile, videoStream, desiredState, currentState);
currentState = SetPad(videoInputFile, videoStream, desiredState, currentState);
currentState = SetCrop(videoInputFile, desiredState, currentState);
SetSubtitle(
videoInputFile,
subtitleInputFile,
@@ -301,7 +304,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
FrameState desiredState,
FrameState currentState)
{
if (currentState.PaddedSize != desiredState.PaddedSize)
if (desiredState.CroppedSize.IsNone && currentState.PaddedSize != desiredState.PaddedSize)
{
IPipelineFilterStep padStep = new PadFilter(currentState, desiredState.PaddedSize);
currentState = padStep.NextState(currentState);
@@ -323,6 +326,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
currentState,
desiredState.ScaledSize,
desiredState.PaddedSize,
desiredState.CroppedSize,
VideoStream.IsAnamorphicEdgeCase);
currentState = scaleStep.NextState(currentState);

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