Compare commits
38 Commits
v0.8.2-bet
...
v0.8.3-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d91e945124 | ||
|
|
9dabffbac1 | ||
|
|
d310b5c09d | ||
|
|
ba48b3a676 | ||
|
|
d8a51b5d6d | ||
|
|
97674cff89 | ||
|
|
4820615308 | ||
|
|
1ddf27ce88 | ||
|
|
cd98a89acd | ||
|
|
a2a6afc3e3 | ||
|
|
dfaba8c7b0 | ||
|
|
5d11a6b46f | ||
|
|
b95a89b11f | ||
|
|
948b3735bd | ||
|
|
5ecf271773 | ||
|
|
b287c0d6ec | ||
|
|
b667659c05 | ||
|
|
22d3025e8e | ||
|
|
8f5b181372 | ||
|
|
f5060522aa | ||
|
|
14a88bd225 | ||
|
|
0550c60a78 | ||
|
|
d3bdcf9bc4 | ||
|
|
714f68a887 | ||
|
|
17bed524f2 | ||
|
|
c3fe263978 | ||
|
|
5291832e6c | ||
|
|
b39dd693f0 | ||
|
|
46bf9ef990 | ||
|
|
bc845b1327 | ||
|
|
3ab8e5bc3a | ||
|
|
e8bc051f73 | ||
|
|
b008fcfd85 | ||
|
|
547db5fb51 | ||
|
|
58fae1b0cc | ||
|
|
694b6bbd91 | ||
|
|
e0f8b7d7ae | ||
|
|
b16215fcd6 |
14
.github/workflows/artifacts.yml
vendored
14
.github/workflows/artifacts.yml
vendored
@@ -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:
|
||||
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public record GetSupportedHardwareAccelerationKinds : IRequest<List<HardwareAccelerationKind>>;
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexLibraries(int PlexMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IPlexBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
_ =>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,4 +10,5 @@ public record TroubleshootingInfo(
|
||||
IEnumerable<FFmpegProfile> FFmpegProfiles,
|
||||
IEnumerable<Channel> Channels,
|
||||
string NvidiaCapabilities,
|
||||
string QsvCapabilities,
|
||||
string VaapiCapabilities);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@ public record FFmpegFullProfileResponseModel(
|
||||
int AudioFormat,
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
bool NormalizeLoudness,
|
||||
int NormalizeLoudnessMode,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
|
||||
12
ErsatzTV.Core/Domain/Collection/PlexCollection.cs
Normal file
12
ErsatzTV.Core/Domain/Collection/PlexCollection.cs
Normal 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; }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
8
ErsatzTV.Core/Domain/NormalizeLoudnessMode.cs
Normal file
8
ErsatzTV.Core/Domain/NormalizeLoudnessMode.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum NormalizeLoudnessMode
|
||||
{
|
||||
Off = 0,
|
||||
LoudNorm = 1,
|
||||
DynAudNorm = 2
|
||||
}
|
||||
8
ErsatzTV.Core/Domain/ScalingBehavior.cs
Normal file
8
ErsatzTV.Core/Domain/ScalingBehavior.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum ScalingBehavior
|
||||
{
|
||||
ScaleAndPad = 0,
|
||||
Stretch = 1,
|
||||
Crop = 2
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
12
ErsatzTV.Core/Interfaces/Plex/IPlexCollectionScanner.cs
Normal file
12
ErsatzTV.Core/Interfaces/Plex/IPlexCollectionScanner.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>())
|
||||
|
||||
8
ErsatzTV.FFmpeg/AudioFilter.cs
Normal file
8
ErsatzTV.FFmpeg/AudioFilter.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.FFmpeg;
|
||||
|
||||
public enum AudioFilter
|
||||
{
|
||||
None = 0,
|
||||
LoudNorm = 1,
|
||||
DynAudNorm = 2
|
||||
}
|
||||
@@ -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
|
||||
|
||||
31
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownDecoder.cs
Normal file
31
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownDecoder.cs
Normal 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
|
||||
};
|
||||
}
|
||||
13
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownEncoder.cs
Normal file
13
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownEncoder.cs
Normal 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>();
|
||||
}
|
||||
19
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFilter.cs
Normal file
19
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownFilter.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
23
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownOption.cs
Normal file
23
ErsatzTV.FFmpeg/Capabilities/FFmpegKnownOption.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
3
ErsatzTV.FFmpeg/Capabilities/Qsv/QsvOutput.cs
Normal file
3
ErsatzTV.FFmpeg/Capabilities/Qsv/QsvOutput.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.FFmpeg.Capabilities.Qsv;
|
||||
|
||||
public record QsvOutput(int ExitCode, string Output);
|
||||
@@ -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);
|
||||
|
||||
|
||||
13
ErsatzTV.FFmpeg/Decoder/Cuvid/DecoderImplicitCuda.cs
Normal file
13
ErsatzTV.FFmpeg/Decoder/Cuvid/DecoderImplicitCuda.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.FFmpeg;
|
||||
|
||||
public record FFmpegPipeline(IList<IPipelineStep> PipelineSteps);
|
||||
public record FFmpegPipeline(IList<IPipelineStep> PipelineSteps, bool IsIntelVaapiOrQsv);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
49
ErsatzTV.FFmpeg/Filter/CropFilter.cs
Normal file
49
ErsatzTV.FFmpeg/Filter/CropFilter.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
64
ErsatzTV.FFmpeg/InputOption/ReadrateInputOption.cs
Normal file
64
ErsatzTV.FFmpeg/InputOption/ReadrateInputOption.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
6
ErsatzTV.FFmpeg/OutputOption/NoAutoScaleOutputOption.cs
Normal file
6
ErsatzTV.FFmpeg/OutputOption/NoAutoScaleOutputOption.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.FFmpeg.OutputOption;
|
||||
|
||||
public class NoAutoScaleOutputOption : OutputOption
|
||||
{
|
||||
public override IList<string> OutputOptions => new List<string> { "-noautoscale" };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,4 +6,5 @@ public record PipelineContext(
|
||||
bool HasSubtitleOverlay,
|
||||
bool HasSubtitleText,
|
||||
bool ShouldDeinterlace,
|
||||
bool Is10BitOutput);
|
||||
bool Is10BitOutput,
|
||||
bool IsIntelVaapiOrQsv);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user