Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0308106c1b | ||
|
|
ba93e3eeea | ||
|
|
aa2c914d8a | ||
|
|
caa9bf82d5 | ||
|
|
e397035c5a | ||
|
|
d32f881c4e | ||
|
|
9b3c24559d | ||
|
|
7c75b169ec | ||
|
|
4f1952340f | ||
|
|
ac2de24f6e | ||
|
|
4b9781dad4 | ||
|
|
d88c179b63 | ||
|
|
809a623a95 | ||
|
|
b453dce57e | ||
|
|
edd31755c0 | ||
|
|
7de1a87bbf | ||
|
|
5731edc82e | ||
|
|
2f668e53dd | ||
|
|
abd223acd2 | ||
|
|
6a9075dc11 | ||
|
|
d5a03963c0 | ||
|
|
f3e5ff198b | ||
|
|
6be5111195 | ||
|
|
f0670b345f | ||
|
|
6a1c2b7659 | ||
|
|
7cd2f9a56f | ||
|
|
f66bc783a7 | ||
|
|
bc225d35fa | ||
|
|
52a8b7db81 | ||
|
|
dcd792a354 |
@@ -51,6 +51,16 @@ jobs:
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.4.5
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
@@ -104,9 +114,9 @@ jobs:
|
||||
- name: Notarize
|
||||
shell: bash
|
||||
run: |
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
gon -log-level=debug -log-json ./gon.json
|
||||
curl -o gon.zip -L -s "https://github.com/mitchellh/gon/releases/latest/download/gon_macos.zip"
|
||||
unzip -o -q gon.zip
|
||||
./gon -log-level=debug -log-json ./gon.json
|
||||
env:
|
||||
AC_USERNAME: ${{ secrets.ac_username }}
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
@@ -166,6 +176,16 @@ jobs:
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.4.5
|
||||
with:
|
||||
working-directory: ErsatzTV/client-app
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Lint VueJS Files on PR Request
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
vue-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Checkout the current repo
|
||||
- name: Checkout current repository
|
||||
uses: actions/checkout@v2
|
||||
# Setup NodeJS version 14
|
||||
- name: Setup NodeJS V14.x.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
# CD into the current client directory and lint and build the client
|
||||
- name: Lint and Build the client
|
||||
run: |
|
||||
cd ./ErsatzTV/client-app/
|
||||
npm ci --no-optional
|
||||
npm run lint
|
||||
npm run build --if-present
|
||||
@@ -43,3 +43,4 @@ scripts/generate-api-sdk/swagger.json
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
ErsatzTV/wwwroot/v2/
|
||||
|
||||
+31
-1
@@ -5,6 +5,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.5-alpha] - 2022-03-29
|
||||
### Fixed
|
||||
- Fix streaming mode inconsistencies when `mode` parameter is unspecified
|
||||
- Fix startup on Windows 7
|
||||
|
||||
### Added
|
||||
- Add option to automatically deinterlace video when transcoding
|
||||
- Previously, this was always enabled; the purpose of the option is to allow disabling any deinterlace filters
|
||||
- Note that there is no performance gain to disabling the option with progressive content; filters are only ever applied to interlaced content
|
||||
|
||||
### Changed
|
||||
- Change FFmpeg Profile video codec and audio codec text fields to select fields
|
||||
- The appropriate video encoder will be determined based on the video format and hardware acceleration selections
|
||||
- Remove FFmpeg Profile `Transcode`, `Normalize Video` and `Normalize Audio` settings
|
||||
- All content will be transcoded and have audio and video normalized
|
||||
- The only exception to this rule is `HLS Direct` streaming mode, which directly copies video and audio streams
|
||||
- Always try to connect to Plex at `http://localhost:32400` even if that address isn't advertised by the Plex API
|
||||
- If Plex isn't on the localhost, all other addresses will be checked as with previous releases
|
||||
|
||||
## [0.4.4-alpha] - 2022-03-10
|
||||
### Fixed
|
||||
- Fix `HLS Direct` streaming mode
|
||||
- Fix bug with `HLS Segmenter` (and `MPEG-TS`) on Windows that caused errors at program boundaries
|
||||
|
||||
### Added
|
||||
- Perform additional duration analysis on files with missing duration metadata
|
||||
- Add `nouveau` VAAPI driver option
|
||||
|
||||
## [0.4.3-alpha] - 2022-03-05
|
||||
### Fixed
|
||||
- Fix song sorting with `Chronological` and `Shuffle In Order` playback orders
|
||||
@@ -1008,7 +1036,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.3-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.5-alpha...HEAD
|
||||
[0.4.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.4-alpha...v0.4.5-alpha
|
||||
[0.4.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.3-alpha...v0.4.4-alpha
|
||||
[0.4.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.2-alpha...v0.4.3-alpha
|
||||
[0.4.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.1-alpha...v0.4.2-alpha
|
||||
[0.4.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.0-alpha...v0.4.1-alpha
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asmichi.ChildProcess" Version="0.11.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Diagnostics;
|
||||
using Asmichi.ProcessManagement;
|
||||
using System.Reflection;
|
||||
using CliWrap;
|
||||
|
||||
namespace ErsatzTV_Windows;
|
||||
|
||||
public class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
private readonly NotifyIcon _trayIcon;
|
||||
private readonly IChildProcess? _childProcess;
|
||||
private readonly CancellationTokenSource _tokenSource;
|
||||
|
||||
public TrayApplicationContext()
|
||||
{
|
||||
@@ -19,6 +18,8 @@ public class TrayApplicationContext : ApplicationContext
|
||||
Visible = true
|
||||
};
|
||||
|
||||
_tokenSource = new CancellationTokenSource();
|
||||
|
||||
AddMenuItem("Launch Web UI", LaunchWebUI);
|
||||
AddMenuItem("Show Logs", ShowLogs);
|
||||
_trayIcon.ContextMenuStrip.Items.Add(new ToolStripSeparator());
|
||||
@@ -29,8 +30,11 @@ public class TrayApplicationContext : ApplicationContext
|
||||
|
||||
if (File.Exists(exe))
|
||||
{
|
||||
var si = new ChildProcessStartInfo(exe);
|
||||
_childProcess = ChildProcess.Start(si);
|
||||
|
||||
Cli.Wrap(exe)
|
||||
.WithWorkingDirectory(folder)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(_tokenSource.Token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +68,7 @@ public class TrayApplicationContext : ApplicationContext
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
_childProcess?.Dispose();
|
||||
_tokenSource?.Cancel();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
@@ -19,11 +20,26 @@ internal static class Mapper
|
||||
channel.FallbackFillerId,
|
||||
channel.Playouts?.Count ?? 0);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
channel.FFmpegProfile.Name,
|
||||
channel.PreferredLanguageCode,
|
||||
GetStreamingMode(channel));
|
||||
|
||||
private static string GetLogo(Channel channel) =>
|
||||
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
|
||||
private static string GetWatermark(Channel channel) =>
|
||||
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Watermark))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
private static string GetStreamingMode(Channel channel) =>
|
||||
channel.StreamingMode switch
|
||||
{
|
||||
StreamingMode.TransportStream => "MPEG-TS (Legacy)",
|
||||
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetAllChannelsForApi : IRequest<List<ChannelResponseModel>>;
|
||||
@@ -0,0 +1,21 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public async Task<List<ChannelResponseModel>> Handle(
|
||||
GetAllChannelsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll()).Flatten();
|
||||
return channels.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelByNumber(string ChannelNumber) : IRequest<Option<ChannelViewModel>>;
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelByNumberHandler : IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetChannelByNumberHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetByNumber(request.ChannelNumber).MapT(ProjectToViewModel);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ public class SynchronizeEmbyLibraryByIdHandler :
|
||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
@@ -77,6 +78,7 @@ public class SynchronizeEmbyLibraryByIdHandler :
|
||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
}
|
||||
@@ -98,15 +100,17 @@ public class SynchronizeEmbyLibraryByIdHandler :
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||
ISynchronizeEmbyLibraryById request) =>
|
||||
(await ValidateConnection(request), await EmbyLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, embyLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
embyLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffprobePath
|
||||
));
|
||||
(connectionParameters, embyLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) =>
|
||||
new RequestParameters(
|
||||
connectionParameters,
|
||||
embyLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffmpegPath,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
|
||||
ISynchronizeEmbyLibraryById request) =>
|
||||
@@ -149,6 +153,13 @@ public class SynchronizeEmbyLibraryByIdHandler :
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffmpegPath =>
|
||||
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
@@ -161,6 +172,7 @@ public class SynchronizeEmbyLibraryByIdHandler :
|
||||
EmbyLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval,
|
||||
string FFmpegPath,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.2" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
|
||||
@@ -7,20 +7,18 @@ namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
public record CreateFFmpegProfile(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool Transcode,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int ResolutionId,
|
||||
bool NormalizeVideo,
|
||||
string VideoCodec,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
string AudioCodec,
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeFramerate) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
bool NormalizeFramerate,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
@@ -18,9 +18,9 @@ public class CreateFFmpegProfileHandler :
|
||||
CreateFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, profile => PersistFFmpegProfile(dbContext, profile));
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
}
|
||||
|
||||
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
|
||||
@@ -39,23 +39,21 @@ public class CreateFFmpegProfileHandler :
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
Transcode = request.Transcode,
|
||||
HardwareAcceleration = request.HardwareAcceleration,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
ResolutionId = resolutionId,
|
||||
NormalizeVideo = request.NormalizeVideo,
|
||||
VideoCodec = request.VideoCodec,
|
||||
VideoFormat = request.VideoFormat,
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
AudioCodec = request.AudioCodec,
|
||||
AudioFormat = request.AudioFormat,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
NormalizeLoudness = request.NormalizeLoudness,
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeAudio = request.NormalizeAudio,
|
||||
NormalizeFramerate = request.NormalizeFramerate
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
});
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
|
||||
@@ -8,20 +8,18 @@ public record UpdateFFmpegProfile(
|
||||
int FFmpegProfileId,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool Transcode,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int ResolutionId,
|
||||
bool NormalizeVideo,
|
||||
string VideoCodec,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
string AudioCodec,
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeFramerate) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
bool NormalizeFramerate,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
@@ -18,9 +18,9 @@ public class
|
||||
UpdateFFmpegProfile request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, p => ApplyUpdateRequest(dbContext, p, request));
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
|
||||
}
|
||||
|
||||
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
|
||||
@@ -30,23 +30,21 @@ public class
|
||||
{
|
||||
p.Name = update.Name;
|
||||
p.ThreadCount = update.ThreadCount;
|
||||
p.Transcode = update.Transcode;
|
||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
|
||||
p.VideoCodec = update.VideoCodec;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.AudioCodec = update.AudioCodec;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
p.AudioBitrate = update.AudioBitrate;
|
||||
p.AudioBufferSize = update.AudioBufferSize;
|
||||
p.NormalizeLoudness = update.Transcode && update.NormalizeLoudness;
|
||||
p.NormalizeLoudness = update.NormalizeLoudness;
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
|
||||
p.NormalizeFramerate = update.Transcode && update.NormalizeFramerate;
|
||||
p.NormalizeFramerate = update.NormalizeFramerate;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return new UpdateFFmpegProfileResult(p.Id);
|
||||
}
|
||||
|
||||
@@ -8,20 +8,18 @@ public record FFmpegProfileViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool Transcode,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
ResolutionViewModel Resolution,
|
||||
bool NormalizeVideo,
|
||||
string VideoCodec,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
string AudioCodec,
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeFramerate);
|
||||
bool NormalizeFramerate,
|
||||
bool DeinterlaceVideo);
|
||||
@@ -10,23 +10,21 @@ internal static class Mapper
|
||||
profile.Id,
|
||||
profile.Name,
|
||||
profile.ThreadCount,
|
||||
profile.Transcode,
|
||||
profile.HardwareAcceleration,
|
||||
profile.VaapiDriver,
|
||||
profile.VaapiDevice,
|
||||
Project(profile.Resolution),
|
||||
profile.NormalizeVideo,
|
||||
profile.VideoCodec,
|
||||
profile.VideoFormat,
|
||||
profile.VideoBitrate,
|
||||
profile.VideoBufferSize,
|
||||
profile.AudioCodec,
|
||||
profile.AudioFormat,
|
||||
profile.AudioBitrate,
|
||||
profile.AudioBufferSize,
|
||||
profile.NormalizeLoudness,
|
||||
profile.AudioChannels,
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeAudio,
|
||||
profile.NormalizeVideo && profile.NormalizeFramerate);
|
||||
profile.NormalizeFramerate,
|
||||
profile.DeinterlaceVideo == true);
|
||||
|
||||
private static ResolutionViewModel Project(Resolution resolution) =>
|
||||
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
|
||||
|
||||
@@ -13,7 +13,7 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
|
||||
GetAllHealthCheckResults request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks();
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
@@ -77,6 +78,7 @@ public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
}
|
||||
@@ -98,15 +100,17 @@ public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
(await ValidateConnection(request), await JellyfinLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
jellyfinLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffprobePath
|
||||
));
|
||||
(connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) =>
|
||||
new RequestParameters(
|
||||
connectionParameters,
|
||||
jellyfinLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffmpegPath,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
@@ -149,6 +153,13 @@ public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffmpegPath =>
|
||||
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
@@ -161,6 +172,7 @@ public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
JellyfinLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval,
|
||||
string FFmpegPath,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(
|
||||
|
||||
@@ -47,21 +47,20 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>.Handle(
|
||||
ForceScanLocalLibrary request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
Task<Either<BaseError, string>> IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>.Handle(
|
||||
ScanLocalLibraryIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private Task<Either<BaseError, string>>
|
||||
Handle(IScanLocalLibrary request) =>
|
||||
private Task<Either<BaseError, string>> Handle(IScanLocalLibrary request, CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(parameters => PerformScan(parameters).Map(_ => parameters.LocalLibrary.Name))
|
||||
.MapT(parameters => PerformScan(parameters, cancellationToken).Map(_ => parameters.LocalLibrary.Name))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Unit> PerformScan(RequestParameters parameters)
|
||||
private async Task<Unit> PerformScan(RequestParameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
(LocalLibrary localLibrary, string ffprobePath, string ffmpegPath, bool forceScan,
|
||||
int libraryRefreshInterval) = parameters;
|
||||
@@ -89,27 +88,34 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
|
||||
case LibraryMediaKind.Movies:
|
||||
await _movieFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
progressMax,
|
||||
cancellationToken);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
await _televisionFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
progressMax,
|
||||
cancellationToken);
|
||||
break;
|
||||
case LibraryMediaKind.MusicVideos:
|
||||
await _musicVideoFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
progressMax,
|
||||
cancellationToken);
|
||||
break;
|
||||
case LibraryMediaKind.OtherVideos:
|
||||
await _otherVideoFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
@@ -120,7 +126,8 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
|
||||
ffprobePath,
|
||||
ffmpegPath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
progressMax,
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ public class
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
@@ -75,6 +76,7 @@ public class
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
}
|
||||
@@ -95,15 +97,17 @@ public class
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(ISynchronizePlexLibraryById request) =>
|
||||
(await ValidateConnection(request), await PlexLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, plexLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
plexLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffprobePath
|
||||
));
|
||||
(connectionParameters, plexLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) =>
|
||||
new RequestParameters(
|
||||
connectionParameters,
|
||||
plexLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffmpegPath,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
|
||||
ISynchronizePlexLibraryById request) =>
|
||||
@@ -146,6 +150,13 @@ public class
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffmpegPath =>
|
||||
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
@@ -158,6 +169,7 @@ public class
|
||||
PlexLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval,
|
||||
string FFmpegPath,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
|
||||
|
||||
@@ -9,10 +9,11 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class
|
||||
SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
|
||||
Either<BaseError, List<PlexMediaSource>>>
|
||||
public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
|
||||
Either<BaseError, List<PlexMediaSource>>>
|
||||
{
|
||||
private const string LocalhostUri = "http://localhost:32400";
|
||||
|
||||
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
|
||||
@@ -72,6 +73,18 @@ public class
|
||||
|
||||
private async Task SynchronizeServer(List<PlexMediaSource> allExisting, PlexMediaSource server)
|
||||
{
|
||||
if (server.Connections.All(c => c.Uri != LocalhostUri))
|
||||
{
|
||||
var localhost = new PlexConnection
|
||||
{
|
||||
PlexMediaSource = server,
|
||||
PlexMediaSourceId = server.Id,
|
||||
Uri = LocalhostUri
|
||||
};
|
||||
|
||||
server.Connections.Add(localhost);
|
||||
}
|
||||
|
||||
Option<PlexMediaSource> maybeExisting =
|
||||
allExisting.Find(s => s.ClientIdentifier == server.ClientIdentifier);
|
||||
|
||||
@@ -98,7 +111,11 @@ public class
|
||||
|
||||
private async Task FindConnectionToActivate(PlexMediaSource server)
|
||||
{
|
||||
var prioritized = server.Connections.OrderBy(pc => pc.IsActive ? 0 : 1).ToList();
|
||||
var prioritized = server.Connections
|
||||
.OrderByDescending(pc => pc.Uri == LocalhostUri)
|
||||
.ThenByDescending(pc => pc.IsActive)
|
||||
.ToList();
|
||||
|
||||
foreach (PlexConnection connection in server.Connections)
|
||||
{
|
||||
connection.IsActive = false;
|
||||
|
||||
@@ -10,13 +10,12 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public StartFFmpegSessionHandler(
|
||||
@@ -24,15 +23,13 @@ public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSess
|
||||
ILogger<StartFFmpegSessionHandler> logger,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IHlsPlaylistFilter hlsPlaylistFilter)
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_configElementRepository = configElementRepository;
|
||||
_hlsPlaylistFilter = hlsPlaylistFilter;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
|
||||
@@ -54,7 +51,7 @@ public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSess
|
||||
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
|
||||
|
||||
// fire and forget worker
|
||||
_ = worker.Run(request.ChannelNumber, idleTimeout)
|
||||
_ = worker.Run(request.ChannelNumber, idleTimeout, cancellationToken)
|
||||
.ContinueWith(
|
||||
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
|
||||
request.ChannelNumber,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
using System.Diagnostics;
|
||||
using System.Timers;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -55,19 +59,27 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
DateTimeOffset filterBefore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
return maybeLines.Map(input => _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input));
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
return maybeLines.Map(input => _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Slim.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Run(string channelNumber, TimeSpan idleTimeout)
|
||||
public async Task Run(string channelNumber, TimeSpan idleTimeout, CancellationToken incomingCancellationToken)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
|
||||
void Cancel(object o, ElapsedEventArgs e) => cts.Cancel();
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
_channelNumber = channelNumber;
|
||||
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_timer = new Timer(idleTimeout.TotalMilliseconds) { AutoReset = false };
|
||||
@@ -77,9 +89,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
CancellationToken cancellationToken = cts.Token;
|
||||
|
||||
_logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
|
||||
|
||||
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
ILocalFileSystem localFileSystem = scope.ServiceProvider.GetRequiredService<ILocalFileSystem>();
|
||||
if (localFileSystem.ListFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber)).Any())
|
||||
{
|
||||
_logger.LogError("Transcode folder is NOT empty!");
|
||||
}
|
||||
|
||||
_targetFramerate = await mediator.Send(
|
||||
new GetChannelFramerate(channelNumber),
|
||||
@@ -187,36 +204,33 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
await TrimAndDelete(cancellationToken);
|
||||
|
||||
Process process = processModel.Process;
|
||||
using Process process = processModel.Process;
|
||||
|
||||
_logger.LogInformation(
|
||||
"ffmpeg hls arguments {FFmpegArguments}",
|
||||
string.Join(" ", process.StartInfo.ArgumentList));
|
||||
|
||||
process.Start();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
process.WaitForExit();
|
||||
await Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(cancellationToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Terminating HLS process for channel {Channel}", _channelNumber);
|
||||
process.Kill();
|
||||
process.WaitForExit();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber);
|
||||
|
||||
_transcodedUntil = processModel.Until;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error transcoding channel {Channel}", _channelNumber);
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
@@ -239,68 +253,82 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
private async Task TrimAndDelete(CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
foreach (string[] lines in maybeLines)
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// trim playlist and insert discontinuity before appending with new ffmpeg process
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
|
||||
_playlistStart,
|
||||
DateTimeOffset.Now.AddMinutes(-1),
|
||||
lines);
|
||||
await WritePlaylist(trimResult.Playlist, cancellationToken);
|
||||
|
||||
// delete old segments
|
||||
var allSegments = Directory.GetFiles(
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
|
||||
"live*.ts")
|
||||
.Map(
|
||||
file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
|
||||
return new Segment(file, sequenceNumber);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
|
||||
// if (toDelete.Count > 0)
|
||||
// {
|
||||
// _logger.LogInformation(
|
||||
// "Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
|
||||
// toDelete.Map(s => s.SequenceNumber).Min(),
|
||||
// toDelete.Map(s => s.SequenceNumber).Max(),
|
||||
// trimResult.Sequence);
|
||||
// }
|
||||
|
||||
foreach (Segment segment in toDelete)
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
foreach (string[] lines in maybeLines)
|
||||
{
|
||||
File.Delete(segment.File);
|
||||
}
|
||||
// trim playlist and insert discontinuity before appending with new ffmpeg process
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
|
||||
_playlistStart,
|
||||
DateTimeOffset.Now.AddMinutes(-1),
|
||||
lines);
|
||||
await WritePlaylist(trimResult.Playlist, cancellationToken);
|
||||
|
||||
_playlistStart = trimResult.PlaylistStart;
|
||||
// delete old segments
|
||||
var allSegments = Directory.GetFiles(
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
|
||||
"live*.ts")
|
||||
.Map(
|
||||
file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
|
||||
return new Segment(file, sequenceNumber);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
|
||||
// if (toDelete.Count > 0)
|
||||
// {
|
||||
// _logger.LogInformation(
|
||||
// "Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
|
||||
// toDelete.Map(s => s.SequenceNumber).Min(),
|
||||
// toDelete.Map(s => s.SequenceNumber).Max(),
|
||||
// trimResult.Sequence);
|
||||
// }
|
||||
|
||||
foreach (Segment segment in toDelete)
|
||||
{
|
||||
File.Delete(segment.File);
|
||||
}
|
||||
|
||||
_playlistStart = trimResult.PlaylistStart;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Slim.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> GetPtsOffset(IMediator mediator, string channelNumber, CancellationToken cancellationToken)
|
||||
private static async Task<long> GetPtsOffset(
|
||||
IMediator mediator,
|
||||
string channelNumber,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
|
||||
Option<FileInfo> lastSegment =
|
||||
Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault());
|
||||
|
||||
long result = 0;
|
||||
foreach (FileInfo segment in lastSegment)
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
long result = 0;
|
||||
|
||||
Either<BaseError, PtsAndDuration> queryResult = await mediator.Send(
|
||||
new GetLastPtsDuration(segment.FullName),
|
||||
new GetLastPtsDuration(channelNumber),
|
||||
cancellationToken);
|
||||
|
||||
foreach (PtsAndDuration ptsAndDuration in queryResult.RightToSeq())
|
||||
foreach ((long pts, long duration) in queryResult.RightToSeq())
|
||||
{
|
||||
result = ptsAndDuration.Pts + ptsAndDuration.Duration;
|
||||
result = pts + duration;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Slim.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> GetWorkAheadLimit()
|
||||
@@ -313,41 +341,25 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
private async Task<Option<string[]>> ReadPlaylistLines(CancellationToken cancellationToken)
|
||||
{
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
try
|
||||
string fileName = PlaylistFileName();
|
||||
if (File.Exists(fileName))
|
||||
{
|
||||
string fileName = PlaylistFileName();
|
||||
if (File.Exists(fileName))
|
||||
{
|
||||
return await File.ReadAllLinesAsync(fileName, cancellationToken);
|
||||
}
|
||||
return await File.ReadAllLinesAsync(fileName, cancellationToken);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Slim.Release();
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
private async Task WritePlaylist(string playlist, CancellationToken cancellationToken)
|
||||
{
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
string fileName = PlaylistFileName();
|
||||
await File.WriteAllTextAsync(fileName, playlist, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Slim.Release();
|
||||
}
|
||||
string fileName = PlaylistFileName();
|
||||
await File.WriteAllTextAsync(fileName, playlist, cancellationToken);
|
||||
}
|
||||
|
||||
private string PlaylistFileName() => Path.Combine(
|
||||
FileSystemLayout.TranscodeFolder,
|
||||
_channelNumber,
|
||||
"live.m3u8");
|
||||
|
||||
|
||||
private record Segment(string File, int SequenceNumber);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
|
||||
return await validation.Match(
|
||||
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
|
||||
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, PlayoutItemProcessModel>>(error.Join()));
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
|
||||
TvContext dbContext,
|
||||
T request,
|
||||
Channel channel,
|
||||
string ffmpegPath);
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
private static async Task<Validation<BaseError, Tuple<Channel, string>>> Validate(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -25,7 +25,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
|
||||
TvContext dbContext,
|
||||
GetConcatProcessByChannelNumber request,
|
||||
Channel channel,
|
||||
string ffmpegPath)
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool saveReports = await dbContext.ConfigElements
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
|
||||
@@ -25,10 +25,10 @@ public class GetHlsPlaylistByChannelNumberHandler :
|
||||
GetHlsPlaylistByChannelNumber request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, now);
|
||||
return await LanguageExtensions.Apply(validation, parameters => GetPlaylist(dbContext, request, parameters, now));
|
||||
return await validation.Apply(parameters => GetPlaylist(dbContext, request, parameters, now));
|
||||
}
|
||||
|
||||
private Task<string> GetPlaylist(
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public record GetLastPtsDuration(string FileName) : IRequest<Either<BaseError, PtsAndDuration>>;
|
||||
public record GetLastPtsDuration(string ChannelNumber) : IRequest<Either<BaseError, PtsAndDuration>>;
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class GetLastPtsDurationHandler : IRequestHandler<GetLastPtsDuration, Either<BaseError, PtsAndDuration>>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILogger<GetLastPtsDurationHandler> _logger;
|
||||
|
||||
public GetLastPtsDurationHandler(IConfigElementRepository configElementRepository)
|
||||
public GetLastPtsDurationHandler(
|
||||
IClient client,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ITempFilePool tempFilePool,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<GetLastPtsDurationHandler> logger)
|
||||
{
|
||||
_client = client;
|
||||
_localFileSystem = localFileSystem;
|
||||
_tempFilePool = tempFilePool;
|
||||
_configElementRepository = configElementRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PtsAndDuration>> Handle(
|
||||
@@ -21,61 +41,118 @@ public class GetLastPtsDurationHandler : IRequestHandler<GetLastPtsDuration, Eit
|
||||
{
|
||||
Validation<BaseError, RequestParameters> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
Handle,
|
||||
parameters => Handle(parameters, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, PtsAndDuration>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(GetLastPtsDuration request) =>
|
||||
await ValidateFFprobePath()
|
||||
.MapT(
|
||||
ffprobePath => new RequestParameters(
|
||||
request.FileName,
|
||||
ffprobePath));
|
||||
await ValidateFFprobePath().MapT(ffprobePath => new RequestParameters(request.ChannelNumber, ffprobePath));
|
||||
|
||||
private async Task<Either<BaseError, PtsAndDuration>> Handle(RequestParameters parameters)
|
||||
private async Task<Either<BaseError, PtsAndDuration>> Handle(
|
||||
RequestParameters parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
Option<FileInfo> maybeLastSegment = GetLastSegment(parameters.ChannelNumber);
|
||||
foreach (FileInfo segment in maybeLastSegment)
|
||||
{
|
||||
FileName = parameters.FFprobePath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add("0");
|
||||
startInfo.ArgumentList.Add("-show_entries");
|
||||
startInfo.ArgumentList.Add("packet=pts,duration");
|
||||
startInfo.ArgumentList.Add("-of");
|
||||
startInfo.ArgumentList.Add("compact=p=0:nk=1");
|
||||
startInfo.ArgumentList.Add("-read_intervals");
|
||||
startInfo.ArgumentList.Add("-999999");
|
||||
startInfo.ArgumentList.Add(parameters.FileName);
|
||||
|
||||
var probe = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
probe.Start();
|
||||
return await probe.StandardOutput.ReadToEndAsync().MapAsync<string, Either<BaseError, PtsAndDuration>>(
|
||||
async output =>
|
||||
string[] argumentList =
|
||||
{
|
||||
await probe.WaitForExitAsync();
|
||||
return probe.ExitCode == 0
|
||||
? PtsAndDuration.From(output.Split("\n").Filter(s => !string.IsNullOrWhiteSpace(s)).Last().Trim())
|
||||
: BaseError.New($"FFprobe at {parameters.FFprobePath} exited with code {probe.ExitCode}");
|
||||
});
|
||||
"-v", "0",
|
||||
"-show_entries",
|
||||
"packet=pts,duration",
|
||||
"-of", "compact=p=0:nk=1",
|
||||
// "-read_intervals", "999999", // read_intervals causes inconsistent behavior on windows
|
||||
segment.FullName
|
||||
};
|
||||
|
||||
string lastLine = string.Empty;
|
||||
Action<string> replaceLine = s =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
lastLine = s.Trim();
|
||||
}
|
||||
};
|
||||
|
||||
CommandResult probe = await Cli.Wrap(parameters.FFprobePath)
|
||||
.WithArguments(argumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(replaceLine))
|
||||
.ExecuteAsync(cancellationToken);
|
||||
|
||||
if (probe.ExitCode != 0)
|
||||
{
|
||||
return BaseError.New($"FFprobe at {parameters.FFprobePath} exited with code {probe.ExitCode}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return PtsAndDuration.From(lastLine);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
await SaveTroubleshootingData(parameters.ChannelNumber, lastLine);
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New($"Failed to determine last pts duration for channel {parameters.ChannelNumber}");
|
||||
}
|
||||
|
||||
private static Option<FileInfo> GetLastSegment(string channelNumber)
|
||||
{
|
||||
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
|
||||
return Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault());
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffprobePath =>
|
||||
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||
.Map(ffprobePath => ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||
|
||||
private record RequestParameters(string FileName, string FFprobePath);
|
||||
private async Task SaveTroubleshootingData(string channelNumber, string output)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
|
||||
FileInfo[] allFiles = directory.GetFiles();
|
||||
|
||||
string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
|
||||
string playlistContents = string.Empty;
|
||||
if (_localFileSystem.FileExists(playlistFileName))
|
||||
{
|
||||
playlistContents = await File.ReadAllTextAsync(playlistFileName);
|
||||
}
|
||||
|
||||
var data = new TroubleshootingData(allFiles, playlistContents, output);
|
||||
string serialized = data.Serialize();
|
||||
|
||||
string file = _tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder);
|
||||
await File.WriteAllTextAsync(file, serialized);
|
||||
|
||||
_logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private record RequestParameters(string ChannelNumber, string FFprobePath);
|
||||
|
||||
private record TroubleshootingData(IEnumerable<FileInfo> Files, string Playlist, string ProbeOutput)
|
||||
{
|
||||
private record FileData(string FileName, long Bytes, DateTime LastWriteTimeUtc);
|
||||
private record InternalData(List<FileData> Files, string EncodedPlaylist, string EncodedProbeOutput);
|
||||
|
||||
public string Serialize()
|
||||
{
|
||||
var data = new InternalData(
|
||||
Files.Map(f => new FileData(f.FullName, f.Length, f.LastWriteTimeUtc)).ToList(),
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes(Playlist)),
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes(ProbeOutput)));
|
||||
|
||||
return JsonConvert.SerializeObject(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-72
@@ -58,7 +58,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
TvContext dbContext,
|
||||
GetPlayoutItemProcessByChannelNumber request,
|
||||
Channel channel,
|
||||
string ffmpegPath)
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTimeOffset now = request.Now;
|
||||
|
||||
@@ -129,7 +130,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
song,
|
||||
channel,
|
||||
maybeGlobalWatermark,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
bool saveReports = await dbContext.ConfigElements
|
||||
@@ -164,85 +166,48 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
foreach (BaseError error in maybePlayoutItem.LeftToSeq())
|
||||
{
|
||||
var offlineTranscodeMessage =
|
||||
$"offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'";
|
||||
|
||||
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
|
||||
.Where(transcode => transcode)
|
||||
.Match(
|
||||
_ => dbContext.PlayoutItems
|
||||
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems
|
||||
.Filter(pi => pi.Playout.ChannelId == channel.Id)
|
||||
.Filter(pi => pi.Start > now.UtcDateTime)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.FirstOrDefaultAsync()
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.Map(Optional)
|
||||
.MapT(pi => pi.StartOffset - now),
|
||||
() => Option<TimeSpan>.None.AsTask());
|
||||
.MapT(pi => pi.StartOffset - now);
|
||||
|
||||
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
|
||||
|
||||
switch (error)
|
||||
{
|
||||
case UnableToLocatePlayoutItem:
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
Process errorProcess = await ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
Process offlineProcess = await ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
else
|
||||
{
|
||||
var message =
|
||||
$"Unable to locate playout item for channel {channel.Number}; {offlineTranscodeMessage}";
|
||||
|
||||
return BaseError.New(message);
|
||||
}
|
||||
return new PlayoutItemProcessModel(offlineProcess, finish);
|
||||
case PlayoutItemDoesNotExistOnDisk:
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
Process errorProcess = await ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
error.Value,
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
Process doesNotExistProcess = await ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
error.Value,
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
else
|
||||
{
|
||||
var message =
|
||||
$"Playout item does not exist on disk for channel {channel.Number}; {offlineTranscodeMessage}";
|
||||
|
||||
return BaseError.New(message);
|
||||
}
|
||||
return new PlayoutItemProcessModel(doesNotExistProcess, finish);
|
||||
default:
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
Process errorProcess = await ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
Process errorProcess = await ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset);
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
else
|
||||
{
|
||||
var message =
|
||||
$"Unexpected error locating playout item for channel {channel.Number}; {offlineTranscodeMessage}";
|
||||
|
||||
return BaseError.New(message);
|
||||
}
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,17 +245,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
// TODO: shuffle? does it really matter since we loop anyway
|
||||
MediaItem item = items[new Random().Next(items.Count)];
|
||||
|
||||
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
|
||||
.Where(transcode => transcode)
|
||||
.Match(
|
||||
_ => dbContext.PlayoutItems
|
||||
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems
|
||||
.Filter(pi => pi.Playout.ChannelId == channel.Id)
|
||||
.Filter(pi => pi.Start > now.UtcDateTime)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(pi => pi.StartOffset - now),
|
||||
() => Option<TimeSpan>.None.AsTask());
|
||||
.MapT(pi => pi.StartOffset - now);
|
||||
|
||||
MediaVersion version = item.GetHeadVersion();
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
|
||||
TvContext dbContext,
|
||||
GetWrappedProcessByChannelNumber request,
|
||||
Channel channel,
|
||||
string ffmpegPath)
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool saveReports = await dbContext.ConfigElements
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
|
||||
@@ -8,18 +8,19 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.5.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.17.1" />
|
||||
<PackageReference Include="Moq" Version="4.17.2" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
|
||||
@@ -9,13 +9,15 @@ namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
[TestFixture]
|
||||
public class FFmpegPlaybackSettingsCalculatorTests
|
||||
{
|
||||
private static readonly MediaVersion TestVersion = new() { SampleAspectRatio = "1:1", Width = 1920, Height = 1080 };
|
||||
|
||||
[TestFixture]
|
||||
public class CalculateSettings
|
||||
{
|
||||
private readonly FFmpegPlaybackSettingsCalculator _calculator;
|
||||
|
||||
public CalculateSettings() => _calculator = new FFmpegPlaybackSettingsCalculator();
|
||||
|
||||
|
||||
[Test]
|
||||
public void Should_Not_GenPts_ForHlsSegmenter()
|
||||
{
|
||||
@@ -24,7 +26,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
@@ -47,7 +49,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
@@ -68,7 +70,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
@@ -89,7 +91,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
@@ -112,7 +114,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
@@ -135,7 +137,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
@@ -156,7 +158,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
@@ -179,7 +181,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
now,
|
||||
@@ -203,7 +205,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
now,
|
||||
@@ -217,33 +219,11 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
actual.StreamSeek.IfNone(TimeSpan.Zero).Should().Be(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ShouldNot_SetScaledSize_When_NotNormalizingVideo_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with { NormalizeVideo = false };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ShouldNot_SetScaledSize_When_ContentIsCorrectSize_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
|
||||
@@ -271,7 +251,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
|
||||
@@ -299,7 +278,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
|
||||
@@ -328,8 +306,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
|
||||
@@ -358,8 +334,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1280, Height = 720 }
|
||||
};
|
||||
|
||||
@@ -389,8 +363,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
|
||||
@@ -414,45 +386,14 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_NotPadToDesiredResolution_When_NotNormalizingVideo()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = false,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetDesiredVideoCodec_When_ContentIsPadded_ForTransportStream()
|
||||
public void Should_SetDesiredVideoFormat_When_ContentIsPadded_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "testCodec"
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
@@ -473,19 +414,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeTrue();
|
||||
actual.VideoCodec.Should().Be("testCodec");
|
||||
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.H264);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetDesiredVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
|
||||
Should_SetDesiredVideoFormat_When_ContentIsCorrectSize_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "testCodec"
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
@@ -507,19 +446,16 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("testCodec");
|
||||
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.H264);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForHttpLiveStreaming()
|
||||
public void Should_SetCopyVideoFormat_When_ContentIsCorrectSize_ForHttpLiveStreamingDirect()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "testCodec"
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
@@ -541,18 +477,16 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.Copy);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_CorrectCodec_ForTransportStream()
|
||||
public void Should_NotSetCopyVideoFormat_When_ContentIsCorrectSize_And_CorrectFormat_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264"
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
@@ -563,7 +497,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "libx264" },
|
||||
new MediaStream { Codec = "h264" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
@@ -574,79 +508,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingVideo_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = false,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264"
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetCopyVideoCodec_AndCopyAudioCodec_When_NotTranscoding_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = false,
|
||||
NormalizeVideo = true,
|
||||
NormalizeAudio = true,
|
||||
NormalizeLoudness = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264"
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
actual.NormalizeLoudness.Should().BeFalse();
|
||||
actual.AudioCodec.Should().Be("copy");
|
||||
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.H264);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -654,8 +516,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBitrate = 2525
|
||||
};
|
||||
@@ -682,12 +542,10 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
|
||||
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBitrate = 2525
|
||||
};
|
||||
@@ -719,8 +577,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBufferSize = 2525
|
||||
};
|
||||
@@ -747,13 +603,10 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetVideoBufferSize_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
|
||||
public void Should_SetVideoBufferSize_When_ContentIsCorrectSize_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoBufferSize = 2525
|
||||
};
|
||||
@@ -781,21 +634,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetDesiredAudioCodec_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
public void Should_SetDesiredAudioFormat_With_CorrectFormat_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "aac" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -805,24 +654,21 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioCodec.Should().Be("aac");
|
||||
actual.AudioFormat.Should().Be(FFmpegProfileAudioFormat.Aac);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetCopyAudioCodec_When_NotNormalizingAudio_ForTransportStream()
|
||||
public void Should_SetDesiredAudioFormat_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudio = false,
|
||||
AudioCodec = "aac"
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -832,53 +678,21 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioCodec.Should().Be("copy");
|
||||
actual.AudioFormat.Should().Be(FFmpegProfileAudioFormat.Aac);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetDesiredAudioCodec_When_NormalizingAudio_ForTransportStream()
|
||||
public void Should_SetCopyAudioFormat_ForHttpLiveStreamingDirect()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioCodec.Should().Be("aac");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetCopyAudioCodec_When_NormalizingAudio_ForHttpLiveStreaming()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -888,26 +702,22 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioCodec.Should().Be("copy");
|
||||
actual.AudioFormat.Should().Be(FFmpegProfileAudioFormat.Copy);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioBitrate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
public void Should_SetAudioBitrate_With_CorrectFormat_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioBitrate = 2424,
|
||||
AudioCodec = "ac3"
|
||||
AudioFormat = FFmpegProfileAudioFormat.Ac3
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -921,22 +731,18 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioBufferSize_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
public void Should_SetAudioBufferSize_With_CorrectFormat_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioBufferSize = 2424,
|
||||
AudioCodec = "ac3"
|
||||
AudioFormat = FFmpegProfileAudioFormat.Ac3
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -950,22 +756,18 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioChannels_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
public void Should_SetAudioChannels_With_CorrectFormat_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "ac3",
|
||||
AudioFormat = FFmpegProfileAudioFormat.Ac3,
|
||||
AudioChannels = 6
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -979,22 +781,18 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioSampleRate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
public void Should_SetAudioSampleRate_With_CorrectFormat_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "ac3",
|
||||
AudioFormat = FFmpegProfileAudioFormat.Ac3,
|
||||
AudioSampleRate = 48
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -1008,21 +806,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioChannels_When_NormalizingAudio_ForTransportStream()
|
||||
public void Should_SetAudioChannels_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioChannels = 6
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -1036,21 +830,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioSampleRate_When_NormalizingAudio_ForTransportStream()
|
||||
public void Should_SetAudioSampleRate_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioSampleRate = 48
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -1064,17 +854,15 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioDuration_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
public void Should_SetAudioDuration_With_CorrectFormat_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
AudioSampleRate = 48,
|
||||
AudioCodec = "ac3"
|
||||
AudioFormat = FFmpegProfileAudioFormat.Ac3
|
||||
};
|
||||
|
||||
var version = new MediaVersion { Duration = TimeSpan.FromMinutes(5) }; // not pulled from here
|
||||
var version = new MediaVersion { SampleAspectRatio = "1:1", Width = 1920, Height = 1080, Duration = TimeSpan.FromMinutes(5) }; // not pulled from here
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
@@ -1093,21 +881,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetNormalizeLoudness_When_NormalizingAudio_ForTransportStream()
|
||||
public void Should_SetNormalizeLoudness_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = true,
|
||||
NormalizeLoudness = true
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
@@ -1119,34 +903,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
|
||||
actual.NormalizeLoudness.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_NotSetNormalizeLoudness_When_NotNormalizingAudio_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
Transcode = true,
|
||||
NormalizeAudio = false,
|
||||
NormalizeLoudness = true
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.NormalizeLoudness.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
@@ -1165,7 +921,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
TestVersion,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -117,21 +118,15 @@ public class TranscodingTests
|
||||
new() { Width = 1280, Height = 720 }
|
||||
};
|
||||
|
||||
public static string[] SoftwareCodecs =
|
||||
{
|
||||
"libx264",
|
||||
"libx265"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NoAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.None
|
||||
};
|
||||
|
||||
public static string[] NvidiaCodecs =
|
||||
public static FFmpegProfileVideoFormat[] VideoFormats =
|
||||
{
|
||||
"h264_nvenc",
|
||||
"hevc_nvenc"
|
||||
FFmpegProfileVideoFormat.H264,
|
||||
FFmpegProfileVideoFormat.Hevc
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NvidiaAcceleration =
|
||||
@@ -139,34 +134,16 @@ public class TranscodingTests
|
||||
HardwareAccelerationKind.Nvenc
|
||||
};
|
||||
|
||||
public static string[] VaapiCodecs =
|
||||
{
|
||||
"h264_vaapi",
|
||||
"hevc_vaapi"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] VaapiAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi
|
||||
};
|
||||
|
||||
public static string[] VideoToolboxCodecs =
|
||||
{
|
||||
"h264_videotoolbox",
|
||||
"hevc_videotoolbox"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] VideoToolboxAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.VideoToolbox
|
||||
};
|
||||
|
||||
public static string[] QsvCodecs =
|
||||
{
|
||||
"h264_qsv",
|
||||
"hevc_qsv"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] QsvAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Qsv
|
||||
@@ -175,25 +152,16 @@ public class TranscodingTests
|
||||
|
||||
[Test, Combinatorial]
|
||||
public async Task Transcode(
|
||||
[ValueSource(typeof(TestData), nameof(TestData.InputFormats))]
|
||||
InputFormat inputFormat,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
|
||||
Resolution profileResolution,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Paddings))]
|
||||
Padding padding,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))]
|
||||
VideoScanKind videoScanKind,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Watermarks))]
|
||||
Watermark watermark,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.InputFormats))] InputFormat inputFormat,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))] Resolution profileResolution,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Paddings))] Padding padding,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))] VideoScanKind videoScanKind,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Watermarks))] Watermark watermark,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.VideoFormats))] FFmpegProfileVideoFormat profileVideoFormat,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.QsvCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.QsvAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
{
|
||||
if (inputFormat.Encoder is "mpeg1video" or "msmpeg4v2" or "msmpeg4v3")
|
||||
@@ -206,16 +174,18 @@ public class TranscodingTests
|
||||
}
|
||||
|
||||
string name = GetStringSha256Hash(
|
||||
$"{inputFormat.Encoder}_{inputFormat.PixelFormat}_{videoScanKind}_{padding}_{profileResolution}_{profileCodec}_{profileAcceleration}");
|
||||
$"{inputFormat.Encoder}_{inputFormat.PixelFormat}_{videoScanKind}_{padding}_{profileResolution}_{profileVideoFormat}_{profileAcceleration}");
|
||||
|
||||
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
string resolution = padding == Padding.WithPadding ? "1920x1060" : "1920x1080";
|
||||
|
||||
string videoFilter = videoScanKind == VideoScanKind.Interlaced ? "-vf tinterlace=interleave_top,fieldorder=tff" : string.Empty;
|
||||
string videoFilter = videoScanKind == VideoScanKind.Interlaced
|
||||
? "-vf tinterlace=interleave_top,fieldorder=tff"
|
||||
: string.Empty;
|
||||
string flags = videoScanKind == VideoScanKind.Interlaced ? "-flags +ildct+ilme" : string.Empty;
|
||||
|
||||
|
||||
string args =
|
||||
$"-y -f lavfi -i anoisesrc=color=brown -f lavfi -i testsrc=duration=1:size={resolution}:rate=30 {videoFilter} -c:a aac -c:v {inputFormat.Encoder} -shortest -pix_fmt {inputFormat.PixelFormat} -strict -2 {flags} {file}";
|
||||
var p1 = new Process
|
||||
@@ -235,7 +205,7 @@ public class TranscodingTests
|
||||
}
|
||||
|
||||
var imageCache = new Mock<IImageCache>();
|
||||
|
||||
|
||||
// always return the static watermark resource
|
||||
imageCache.Setup(
|
||||
ic => ic.GetPathForImage(
|
||||
@@ -269,11 +239,12 @@ public class TranscodingTests
|
||||
var metadataRepository = new Mock<IMetadataRepository>();
|
||||
metadataRepository
|
||||
.Setup(r => r.UpdateLocalStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
|
||||
.Callback<MediaItem, MediaVersion, bool>((_, version, _) =>
|
||||
{
|
||||
version.MediaFiles = v.MediaFiles;
|
||||
v = version;
|
||||
});
|
||||
.Callback<MediaItem, MediaVersion, bool>(
|
||||
(_, version, _) =>
|
||||
{
|
||||
version.MediaFiles = v.MediaFiles;
|
||||
v = version;
|
||||
});
|
||||
|
||||
var localStatisticsProvider = new LocalStatisticsProvider(
|
||||
metadataRepository.Object,
|
||||
@@ -282,6 +253,7 @@ public class TranscodingTests
|
||||
LoggerFactory.CreateLogger<LocalStatisticsProvider>());
|
||||
|
||||
await localStatisticsProvider.RefreshStatistics(
|
||||
ExecutableName("ffmpeg"),
|
||||
ExecutableName("ffprobe"),
|
||||
new Movie
|
||||
{
|
||||
@@ -344,7 +316,7 @@ public class TranscodingTests
|
||||
break;
|
||||
}
|
||||
|
||||
Process process = await service.ForPlayoutItem(
|
||||
using Process process = await service.ForPlayoutItem(
|
||||
ExecutableName("ffmpeg"),
|
||||
false,
|
||||
new Channel(Guid.NewGuid())
|
||||
@@ -353,8 +325,8 @@ public class TranscodingTests
|
||||
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
|
||||
{
|
||||
HardwareAcceleration = profileAcceleration,
|
||||
VideoCodec = profileCodec,
|
||||
AudioCodec = "aac"
|
||||
VideoFormat = profileVideoFormat,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
},
|
||||
StreamingMode = StreamingMode.TransportStream
|
||||
},
|
||||
@@ -375,13 +347,8 @@ public class TranscodingTests
|
||||
0,
|
||||
None);
|
||||
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
process.EnableRaisingEvents = true;
|
||||
|
||||
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
|
||||
|
||||
process.Start().Should().BeTrue();
|
||||
|
||||
string[] unsupportedMessages =
|
||||
{
|
||||
"No support for codec",
|
||||
@@ -389,41 +356,31 @@ public class TranscodingTests
|
||||
"Provided device doesn't support"
|
||||
};
|
||||
|
||||
var errorBuffer = new StringBuilder();
|
||||
|
||||
process.ErrorDataReceived += (_, errorLine) =>
|
||||
{
|
||||
string data = errorLine.Data ?? string.Empty;
|
||||
errorBuffer.AppendLine(data);
|
||||
};
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
// string error = await process.StandardError.ReadToEndAsync();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
CommandResult result;
|
||||
var timeoutSignal = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutSignal.Token);
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
process.WaitForExit();
|
||||
result = await Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(sb))
|
||||
.ExecuteAsync(timeoutSignal.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill();
|
||||
|
||||
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||
Assert.Fail($"Transcode failure (timeout): ffmpeg {string.Join(" ", quotedArgs)}");
|
||||
return;
|
||||
}
|
||||
|
||||
var error = errorBuffer.ToString();
|
||||
bool isUnsupported = unsupportedMessages.Any(error.Contains);
|
||||
string error = sb.ToString();
|
||||
bool isUnsupported = unsupportedMessages.Any(error.Contains);
|
||||
|
||||
if (profileAcceleration != HardwareAccelerationKind.None && isUnsupported)
|
||||
{
|
||||
var quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'").ToList();
|
||||
process.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
|
||||
result.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
|
||||
Assert.Warn($"Unsupported on this hardware: ffmpeg {string.Join(" ", quotedArgs)}");
|
||||
}
|
||||
else if (error.Contains("Impossible to convert between"))
|
||||
@@ -434,14 +391,14 @@ public class TranscodingTests
|
||||
else
|
||||
{
|
||||
var quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'").ToList();
|
||||
process.ExitCode.Should().Be(0, errorBuffer + Environment.NewLine + string.Join(" ", quotedArgs));
|
||||
if (process.ExitCode == 0)
|
||||
result.ExitCode.Should().Be(0, error + Environment.NewLine + string.Join(" ", quotedArgs));
|
||||
if (result.ExitCode == 0)
|
||||
{
|
||||
Console.WriteLine(string.Join(" ", quotedArgs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static string GetStringSha256Hash(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
|
||||
@@ -27,6 +27,10 @@ public class MovieFolderScannerTests
|
||||
? @"C:\Movies"
|
||||
: "/movies";
|
||||
|
||||
private static readonly string FFmpegPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? @"C:\bin\ffmpeg.exe"
|
||||
: "/bin/ffmpeg";
|
||||
|
||||
private static readonly string FFprobePath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? @"C:\bin\ffprobe.exe"
|
||||
: "/bin/ffprobe";
|
||||
@@ -52,8 +56,9 @@ public class MovieFolderScannerTests
|
||||
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
|
||||
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
|
||||
|
||||
_localStatisticsProvider.Setup(x => x.RefreshStatistics(It.IsAny<string>(), It.IsAny<MediaItem>()))
|
||||
.Returns<string, MediaItem>((_, _) => Right<BaseError, bool>(true).AsTask());
|
||||
_localStatisticsProvider.Setup(
|
||||
x => x.RefreshStatistics(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MediaItem>()))
|
||||
.Returns<string, string, MediaItem>((_, _, _) => Right<BaseError, bool>(true).AsTask());
|
||||
|
||||
// fallback metadata adds metadata to a movie, so we need to replicate that here
|
||||
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<Movie>()))
|
||||
@@ -90,9 +95,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -101,6 +108,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -131,9 +139,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -142,6 +152,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -173,9 +184,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -184,6 +197,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -219,9 +233,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -230,6 +246,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -268,9 +285,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -279,6 +298,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -317,9 +337,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -328,6 +350,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -365,9 +388,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -376,6 +401,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -407,9 +433,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -418,6 +446,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -451,9 +480,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -462,6 +493,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -489,9 +521,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -500,6 +534,7 @@ public class MovieFolderScannerTests
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
@@ -532,9 +567,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -561,9 +598,11 @@ public class MovieFolderScannerTests
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFmpegPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
public record ChannelResponseModel(
|
||||
int Id,
|
||||
string Number,
|
||||
string Name,
|
||||
string FFmpegProfile,
|
||||
string Language,
|
||||
string StreamingMode);
|
||||
@@ -7,35 +7,32 @@ public record FFmpegProfile
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int ThreadCount { get; set; }
|
||||
public bool Transcode { get; set; }
|
||||
public HardwareAccelerationKind HardwareAcceleration { get; set; }
|
||||
public VaapiDriver VaapiDriver { get; set; }
|
||||
public string VaapiDevice { get; set; }
|
||||
public int ResolutionId { get; set; }
|
||||
public Resolution Resolution { get; set; }
|
||||
public string VideoCodec { get; set; }
|
||||
public bool NormalizeVideo { get; set; }
|
||||
public FFmpegProfileVideoFormat VideoFormat { get; set; }
|
||||
public int VideoBitrate { get; set; }
|
||||
public int VideoBufferSize { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public FFmpegProfileAudioFormat AudioFormat { get; set; }
|
||||
public int AudioBitrate { get; set; }
|
||||
public int AudioBufferSize { get; set; }
|
||||
public bool NormalizeLoudness { get; set; }
|
||||
public int AudioChannels { get; set; }
|
||||
public int AudioSampleRate { get; set; }
|
||||
public bool NormalizeAudio { get; set; }
|
||||
public bool NormalizeFramerate { get; set; }
|
||||
public bool? DeinterlaceVideo { get; set; }
|
||||
|
||||
public static FFmpegProfile New(string name, Resolution resolution) =>
|
||||
new()
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = 0,
|
||||
Transcode = true,
|
||||
ResolutionId = resolution.Id,
|
||||
Resolution = resolution,
|
||||
VideoCodec = "libx264",
|
||||
AudioCodec = "ac3",
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Ac3,
|
||||
VideoBitrate = 2000,
|
||||
VideoBufferSize = 4000,
|
||||
AudioBitrate = 192,
|
||||
@@ -43,8 +40,8 @@ public record FFmpegProfile
|
||||
NormalizeLoudness = true,
|
||||
AudioChannels = 2,
|
||||
AudioSampleRate = 48,
|
||||
NormalizeVideo = true,
|
||||
NormalizeAudio = true,
|
||||
DeinterlaceVideo = true,
|
||||
NormalizeFramerate = false,
|
||||
HardwareAcceleration = HardwareAccelerationKind.None
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum FFmpegProfileAudioFormat
|
||||
{
|
||||
None = 0,
|
||||
|
||||
Aac = 1,
|
||||
Ac3 = 2,
|
||||
|
||||
Copy = 99
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum FFmpegProfileVideoFormat
|
||||
{
|
||||
None = 0,
|
||||
|
||||
H264 = 1,
|
||||
Hevc = 2,
|
||||
Mpeg2Video = 3,
|
||||
|
||||
Copy = 99
|
||||
}
|
||||
@@ -51,6 +51,7 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<EmbyItemEtag> existingMovies = await _movieRepository.GetExistingEmbyMovies(library);
|
||||
@@ -172,7 +173,11 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
incomingMovie,
|
||||
localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -51,6 +51,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<EmbyItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
|
||||
@@ -70,7 +71,15 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
await maybeShows.Match(
|
||||
async shows =>
|
||||
{
|
||||
await ProcessShows(address, apiKey, library, ffprobePath, pathReplacements, existingShows, shows);
|
||||
await ProcessShows(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
existingShows,
|
||||
shows);
|
||||
|
||||
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
|
||||
var showIds = existingShows
|
||||
@@ -104,6 +113,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<EmbyPathReplacement> pathReplacements,
|
||||
List<EmbyItemEtag> existingShows,
|
||||
@@ -167,6 +177,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
@@ -196,6 +207,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<EmbyPathReplacement> pathReplacements,
|
||||
EmbyShow show,
|
||||
@@ -286,6 +298,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
@@ -318,6 +331,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
string showName,
|
||||
string seasonName,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<EmbyPathReplacement> pathReplacements,
|
||||
EmbySeason season,
|
||||
@@ -406,7 +420,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, incomingEpisode, localPath);
|
||||
|
||||
refreshResult.Match(
|
||||
_ => { },
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -22,7 +22,7 @@ public class FFmpegComplexFilterBuilder
|
||||
private Option<int> _watermarkIndex;
|
||||
private string _pixelFormat;
|
||||
private string _videoDecoder;
|
||||
private string _videoEncoder;
|
||||
private FFmpegProfileVideoFormat _videoFormat;
|
||||
private Option<string> _subtitle;
|
||||
private bool _boxBlur;
|
||||
|
||||
@@ -131,9 +131,9 @@ public class FFmpegComplexFilterBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithVideoEncoder(string videoEncoder)
|
||||
public FFmpegComplexFilterBuilder WithVideoFormat(FFmpegProfileVideoFormat videoFormat)
|
||||
{
|
||||
_videoEncoder = videoEncoder;
|
||||
_videoFormat = videoFormat;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -379,21 +379,21 @@ public class FFmpegComplexFilterBuilder
|
||||
string outputPixelFormat = null;
|
||||
if (!usesSoftwareFilters && string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
switch (acceleration, _videoEncoder, _pixelFormat)
|
||||
switch (acceleration, _videoFormat, _pixelFormat)
|
||||
{
|
||||
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv420p10le"):
|
||||
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le"):
|
||||
outputPixelFormat = "yuv420p";
|
||||
break;
|
||||
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv444p10le"):
|
||||
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv444p10le"):
|
||||
outputPixelFormat = "yuv444p";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
string outputFormat = (_videoEncoder, _pixelFormat) switch
|
||||
string outputFormat = (acceleration, _videoFormat, _pixelFormat) switch
|
||||
{
|
||||
("hevc_nvenc", "yuv420p10le") => "p010le",
|
||||
("h264_nvenc", "yuv420p10le") => "p010le",
|
||||
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
|
||||
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -485,7 +485,7 @@ public class FFmpegComplexFilterBuilder
|
||||
|
||||
// vaapi downsample 10bit hevc to 8bit h264
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && !videoFilterQueue.Any() &&
|
||||
_pixelFormat == "yuv420p10le" && _videoEncoder.StartsWith("h264"))
|
||||
_pixelFormat == "yuv420p10le" && _videoFormat == FFmpegProfileVideoFormat.H264)
|
||||
{
|
||||
videoFilterQueue.Add("scale_vaapi=format=nv12");
|
||||
}
|
||||
|
||||
@@ -85,9 +85,17 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
playbackSettings.StreamSeek,
|
||||
wm.FrequencyMinutes,
|
||||
wm.DurationSeconds));
|
||||
|
||||
string audioFormat = playbackSettings.AudioFormat switch
|
||||
{
|
||||
FFmpegProfileAudioFormat.Aac => AudioFormat.Aac,
|
||||
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
|
||||
FFmpegProfileAudioFormat.Copy => AudioFormat.Copy,
|
||||
_ => throw new ArgumentOutOfRangeException($"unexpected audio format {playbackSettings.VideoFormat}")
|
||||
};
|
||||
|
||||
var audioState = new AudioState(
|
||||
playbackSettings.AudioCodec,
|
||||
audioFormat,
|
||||
playbackSettings.AudioChannels,
|
||||
playbackSettings.AudioBitrate,
|
||||
playbackSettings.AudioBufferSize,
|
||||
@@ -114,14 +122,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
|
||||
var watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
|
||||
|
||||
// TODO: need formats for these codecs
|
||||
string videoFormat = playbackSettings.VideoCodec switch
|
||||
string videoFormat = playbackSettings.VideoFormat switch
|
||||
{
|
||||
"libx265" or "hevc_nvenc" or "hevc_qsv" or "hevc_vaapi" or "hevc_videotoolbox" => VideoFormat.Hevc,
|
||||
"libx264" or "h264_nvenc" or "h264_qsv" or "h264_vaapi" or "h264_videotoolbox" => VideoFormat.H264,
|
||||
"mpeg2video" => VideoFormat.Mpeg2Video,
|
||||
"copy" => VideoFormat.Copy,
|
||||
_ => throw new ArgumentOutOfRangeException($"unexpected video codec {playbackSettings.VideoCodec}")
|
||||
FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc,
|
||||
FFmpegProfileVideoFormat.H264 => VideoFormat.H264,
|
||||
FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video,
|
||||
FFmpegProfileVideoFormat.Copy => VideoFormat.Copy,
|
||||
_ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}")
|
||||
};
|
||||
|
||||
HardwareAccelerationMode hwAccel = playbackSettings.HardwareAcceleration switch
|
||||
@@ -301,7 +308,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
WatermarkLocation watermarkLocation,
|
||||
int horizontalMarginPercent,
|
||||
int verticalMarginPercent,
|
||||
int watermarkWidthPercent) =>
|
||||
int watermarkWidthPercent,
|
||||
CancellationToken cancellationToken) =>
|
||||
_ffmpegProcessService.GenerateSongImage(
|
||||
ffmpegPath,
|
||||
subtitleFile,
|
||||
@@ -314,7 +322,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
watermarkLocation,
|
||||
horizontalMarginPercent,
|
||||
verticalMarginPercent,
|
||||
watermarkWidthPercent);
|
||||
watermarkWidthPercent,
|
||||
cancellationToken);
|
||||
|
||||
private Process GetProcess(
|
||||
string ffmpegPath,
|
||||
@@ -389,6 +398,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
return "iHD";
|
||||
case VaapiDriver.RadeonSI:
|
||||
return "radeonsi";
|
||||
case VaapiDriver.Nouveau:
|
||||
return "nouveau";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ public class FFmpegPlaybackSettings
|
||||
public Option<TimeSpan> StreamSeek { get; set; }
|
||||
public Option<IDisplaySize> ScaledSize { get; set; }
|
||||
public bool PadToDesiredResolution { get; set; }
|
||||
public string VideoCodec { get; set; }
|
||||
public FFmpegProfileVideoFormat VideoFormat { get; set; }
|
||||
public Option<int> VideoBitrate { get; set; }
|
||||
public Option<int> VideoBufferSize { get; set; }
|
||||
public Option<int> AudioBitrate { get; set; }
|
||||
@@ -21,7 +21,7 @@ public class FFmpegPlaybackSettings
|
||||
public Option<int> AudioChannels { get; set; }
|
||||
public Option<int> AudioSampleRate { get; set; }
|
||||
public Option<TimeSpan> AudioDuration { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public FFmpegProfileAudioFormat AudioFormat { get; set; }
|
||||
public bool Deinterlace { get; set; }
|
||||
public Option<int> VideoTrackTimeScale { get; set; }
|
||||
public bool NormalizeLoudness { get; set; }
|
||||
|
||||
@@ -82,8 +82,8 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
switch (streamingMode)
|
||||
{
|
||||
case StreamingMode.HttpLiveStreamingDirect:
|
||||
result.AudioCodec = "copy";
|
||||
result.VideoCodec = "copy";
|
||||
result.AudioFormat = FFmpegProfileAudioFormat.Copy;
|
||||
result.VideoFormat = FFmpegProfileVideoFormat.Copy;
|
||||
result.Deinterlace = false;
|
||||
break;
|
||||
case StreamingMode.TransportStreamHybrid:
|
||||
@@ -98,91 +98,71 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
{
|
||||
int fixedHeight = scaledSize.Height + scaledSize.Height % 2;
|
||||
int fixedWidth = scaledSize.Width + scaledSize.Width % 2;
|
||||
result.ScaledSize = Some((IDisplaySize) new DisplaySize(fixedWidth, fixedHeight));
|
||||
result.ScaledSize = Some((IDisplaySize)new DisplaySize(fixedWidth, fixedHeight));
|
||||
}
|
||||
}
|
||||
|
||||
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(videoVersion);
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
|
||||
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
|
||||
{
|
||||
result.PadToDesiredResolution = true;
|
||||
}
|
||||
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo)
|
||||
if (ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
if (ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
result.FrameRate = targetFramerate;
|
||||
}
|
||||
|
||||
result.VideoTrackTimeScale = 90000;
|
||||
result.FrameRate = targetFramerate;
|
||||
}
|
||||
|
||||
result.VideoTrackTimeScale = 90000;
|
||||
|
||||
foreach (MediaStream stream in videoStream.Where(s => s.AttachedPic == false))
|
||||
{
|
||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, stream))
|
||||
{
|
||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
|
||||
result.VideoFormat = ffmpegProfile.VideoFormat;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
|
||||
|
||||
result.VideoDecoder =
|
||||
(result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
|
||||
{
|
||||
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
|
||||
) =>
|
||||
"h264",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
|
||||
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
|
||||
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
|
||||
|
||||
// temp disable mpeg4 hardware decoding for all vaapi
|
||||
// TODO: check for codec support
|
||||
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
|
||||
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
result.VideoCodec = "copy";
|
||||
}
|
||||
}
|
||||
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
|
||||
{
|
||||
result.AudioCodec = ffmpegProfile.AudioCodec;
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
|
||||
|
||||
audioStream.IfSome(
|
||||
stream =>
|
||||
result.VideoDecoder =
|
||||
(result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
|
||||
{
|
||||
if (stream.Channels != ffmpegProfile.AudioChannels)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
});
|
||||
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
|
||||
) =>
|
||||
"h264",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
|
||||
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
|
||||
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
|
||||
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
|
||||
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
|
||||
|
||||
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
|
||||
result.AudioDuration = outPoint - inPoint;
|
||||
result.NormalizeLoudness = ffmpegProfile.NormalizeLoudness;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.AudioCodec = "copy";
|
||||
// temp disable mpeg4 hardware decoding for all vaapi
|
||||
// TODO: check for codec support
|
||||
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
|
||||
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
if (videoVersion.VideoScanKind == VideoScanKind.Interlaced)
|
||||
{
|
||||
result.Deinterlace = true;
|
||||
}
|
||||
result.AudioFormat = ffmpegProfile.AudioFormat;
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
|
||||
|
||||
audioStream.IfSome(
|
||||
stream =>
|
||||
{
|
||||
if (stream.Channels != ffmpegProfile.AudioChannels)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
});
|
||||
|
||||
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
|
||||
result.AudioDuration = outPoint - inPoint;
|
||||
result.NormalizeLoudness = ffmpegProfile.NormalizeLoudness;
|
||||
|
||||
result.Deinterlace = ffmpegProfile.DeinterlaceVideo == true &&
|
||||
videoVersion.VideoScanKind == VideoScanKind.Interlaced;
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -192,25 +172,17 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
|
||||
public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile)
|
||||
{
|
||||
string softwareCodec = ffmpegProfile.VideoCodec switch
|
||||
{
|
||||
{ } c when c.Contains("hevc") || c.Contains("265") => "libx265",
|
||||
{ } c when c.Contains("264") => "libx264",
|
||||
{ } c when c.Contains("mpeg2") => "mpeg2video",
|
||||
_ => "libx264"
|
||||
};
|
||||
|
||||
return new FFmpegPlaybackSettings
|
||||
{
|
||||
HardwareAcceleration = HardwareAccelerationKind.None,
|
||||
ThreadCount = ffmpegProfile.ThreadCount,
|
||||
FormatFlags = CommonFormatFlags,
|
||||
VideoCodec = softwareCodec,
|
||||
AudioCodec = ffmpegProfile.AudioCodec,
|
||||
VideoFormat = ffmpegProfile.VideoFormat,
|
||||
AudioFormat = ffmpegProfile.AudioFormat
|
||||
};
|
||||
}
|
||||
|
||||
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
|
||||
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo &&
|
||||
IsIncorrectSize(ffmpegProfile.Resolution, version) ||
|
||||
IsTooLarge(ffmpegProfile.Resolution, version) ||
|
||||
IsOddSize(version);
|
||||
@@ -227,9 +199,6 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
private static bool IsOddSize(MediaVersion version) =>
|
||||
version.Height % 2 == 1 || version.Width % 2 == 1;
|
||||
|
||||
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaStream videoStream) =>
|
||||
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
|
||||
|
||||
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
|
||||
{
|
||||
IDisplaySize sarSize = SARSize(version);
|
||||
|
||||
@@ -71,7 +71,10 @@ internal class FFmpegProcessBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, Option<string> pixelFormat, string encoder)
|
||||
public FFmpegProcessBuilder WithHardwareAcceleration(
|
||||
HardwareAccelerationKind hwAccel,
|
||||
Option<string> pixelFormat,
|
||||
FFmpegProfileVideoFormat videoFormat)
|
||||
{
|
||||
_hwAccel = hwAccel;
|
||||
|
||||
@@ -84,14 +87,14 @@ internal class FFmpegProcessBuilder
|
||||
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
|
||||
break;
|
||||
case HardwareAccelerationKind.Nvenc:
|
||||
string outputFormat = (encoder, pixelFormat.IfNone("")) switch
|
||||
string outputFormat = (videoFormat, pixelFormat.IfNone("")) switch
|
||||
{
|
||||
("hevc_nvenc", "yuv420p10le") => "p010le",
|
||||
("h264_nvenc", "yuv420p10le") => "p010le",
|
||||
(FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
|
||||
(FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
|
||||
// ("hevc_nvenc", "yuv444p10le") => "p016le",
|
||||
_ => "cuda"
|
||||
};
|
||||
|
||||
|
||||
_arguments.Add("-hwaccel");
|
||||
_arguments.Add("cuda");
|
||||
_arguments.Add("-hwaccel_output_format");
|
||||
@@ -462,14 +465,17 @@ internal class FFmpegProcessBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
|
||||
public FFmpegProcessBuilder WithPlaybackArgs(
|
||||
FFmpegPlaybackSettings playbackSettings,
|
||||
string videoCodec,
|
||||
string audioCodec)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-c:v", playbackSettings.VideoCodec,
|
||||
"-c:v", videoCodec,
|
||||
"-flags", "cgop",
|
||||
// disable scene change detection except with mpeg2video
|
||||
"-sc_threshold", playbackSettings.VideoCodec == "mpeg2video" ? "1000000000" : "0"
|
||||
"-sc_threshold", playbackSettings.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video ? "1000000000" : "0"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_outputPixelFormat))
|
||||
@@ -512,7 +518,7 @@ internal class FFmpegProcessBuilder
|
||||
arguments.AddRange(
|
||||
new[]
|
||||
{
|
||||
"-c:a", playbackSettings.AudioCodec,
|
||||
"-c:a", audioCodec,
|
||||
"-movflags", "+faststart",
|
||||
"-muxdelay", "0",
|
||||
"-muxpreload", "0"
|
||||
@@ -591,9 +597,9 @@ internal class FFmpegProcessBuilder
|
||||
Option<MediaStream> maybeAudioStream,
|
||||
string videoPath,
|
||||
Option<string> audioPath,
|
||||
string videoCodec)
|
||||
FFmpegProfileVideoFormat videoFormat)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithVideoFormat(videoFormat);
|
||||
|
||||
int videoStreamIndex = videoStream.Index;
|
||||
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
|
||||
@@ -682,6 +688,9 @@ internal class FFmpegProcessBuilder
|
||||
case VaapiDriver.RadeonSI:
|
||||
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "radeonsi";
|
||||
break;
|
||||
case VaapiDriver.Nouveau:
|
||||
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "nouveau";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -34,7 +35,7 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Process> ForPlayoutItem(
|
||||
public Task<Process> ForPlayoutItem(
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
@@ -55,144 +56,7 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
long ptsOffset,
|
||||
Option<int> targetFramerate)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
videoVersion,
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
start,
|
||||
now,
|
||||
inPoint,
|
||||
outPoint,
|
||||
hlsRealtime,
|
||||
targetFramerate);
|
||||
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
|
||||
|
||||
Option<List<FadePoint>> maybeFadePoints = watermarkOptions
|
||||
.Map(o => o.Watermark)
|
||||
.Flatten()
|
||||
.Where(wm => wm.Mode == ChannelWatermarkMode.Intermittent)
|
||||
.Map(
|
||||
wm =>
|
||||
WatermarkCalculator.CalculateFadePoints(
|
||||
start,
|
||||
inPoint,
|
||||
outPoint,
|
||||
playbackSettings.StreamSeek,
|
||||
wm.FrequencyMinutes,
|
||||
wm.DurationSeconds));
|
||||
|
||||
// foreach (List<FadePoint> fadePoints in maybeFadePoints)
|
||||
// {
|
||||
// foreach (FadePoint fadePoint in fadePoints)
|
||||
// {
|
||||
// _logger.LogDebug("Fade point filter: {FadePointFilter}", fadePoint.ToFilter());
|
||||
// }
|
||||
// }
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithVaapiDriver(vaapiDriver, vaapiDevice)
|
||||
.WithHardwareAcceleration(
|
||||
playbackSettings.HardwareAcceleration,
|
||||
videoStream.PixelFormat,
|
||||
playbackSettings.VideoCodec)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithInputCodec(
|
||||
playbackSettings.StreamSeek,
|
||||
fillerKind == FillerKind.Fallback,
|
||||
videoPath,
|
||||
audioPath,
|
||||
playbackSettings.VideoDecoder,
|
||||
videoStream.Codec,
|
||||
videoStream.PixelFormat,
|
||||
playbackSettings.Deinterlace)
|
||||
.WithWatermark(watermarkOptions, maybeFadePoints, channel.FFmpegProfile.Resolution)
|
||||
.WithFrameRate(playbackSettings.FrameRate)
|
||||
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
|
||||
.WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None)
|
||||
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
|
||||
|
||||
playbackSettings.ScaledSize.Match(
|
||||
scaledSize =>
|
||||
{
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithScaling(scaledSize);
|
||||
|
||||
if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize))
|
||||
{
|
||||
builder = builder.WithBlackBars(channel.FFmpegProfile.Resolution);
|
||||
}
|
||||
|
||||
builder = builder
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
if (playbackSettings.PadToDesiredResolution)
|
||||
{
|
||||
builder = builder
|
||||
.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithBlackBars(channel.FFmpegProfile.Resolution)
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
else if (playbackSettings.Deinterlace)
|
||||
{
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder = builder
|
||||
.WithFilterComplex(
|
||||
videoStream,
|
||||
maybeAudioStream,
|
||||
videoPath,
|
||||
audioPath,
|
||||
channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
});
|
||||
|
||||
builder = builder.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel, maybeAudioStream)
|
||||
.WithDuration(finish - now);
|
||||
|
||||
return channel.StreamingMode switch
|
||||
{
|
||||
// HLS needs to segment and generate playlist
|
||||
StreamingMode.HttpLiveStreamingSegmenter => builder.WithHls(
|
||||
channel.Number,
|
||||
videoVersion,
|
||||
ptsOffset,
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.FrameRate)
|
||||
.Build(),
|
||||
_ => builder.WithFormat("mpegts").WithInitialDiscontinuity().WithPipe().Build()
|
||||
};
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public async Task<Process> ForError(
|
||||
@@ -224,6 +88,19 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
var videoStream = new MediaStream { Index = 0 };
|
||||
var audioStream = new MediaStream { Index = 0 };
|
||||
|
||||
string videoCodec = playbackSettings.VideoFormat switch
|
||||
{
|
||||
FFmpegProfileVideoFormat.Hevc => "libx265",
|
||||
FFmpegProfileVideoFormat.Mpeg2Video => "mpeg2video",
|
||||
_ => "libx264"
|
||||
};
|
||||
|
||||
string audioCodec = playbackSettings.AudioFormat switch
|
||||
{
|
||||
FFmpegProfileAudioFormat.Ac3 => "ac3",
|
||||
_ => "aac"
|
||||
};
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
@@ -238,9 +115,9 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
audioStream,
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"),
|
||||
"fake-audio-path",
|
||||
playbackSettings.VideoCodec)
|
||||
playbackSettings.VideoFormat)
|
||||
.WithPixfmt("yuv420p")
|
||||
.WithPlaybackArgs(playbackSettings)
|
||||
.WithPlaybackArgs(playbackSettings, videoCodec, audioCodec)
|
||||
.WithMetadata(channel, None);
|
||||
|
||||
await duration.IfSomeAsync(d => builder = builder.WithDuration(d));
|
||||
@@ -265,19 +142,7 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
|
||||
public Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
|
||||
|
||||
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithInfiniteLoop()
|
||||
.WithConcat($"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}")
|
||||
.WithMetadata(channel, None)
|
||||
.WithFormat("mpegts")
|
||||
.WithPipe()
|
||||
.Build();
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
|
||||
@@ -331,7 +196,8 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
WatermarkLocation watermarkLocation,
|
||||
int horizontalMarginPercent,
|
||||
int verticalMarginPercent,
|
||||
int watermarkWidthPercent)
|
||||
int watermarkWidthPercent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -396,7 +262,7 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
None,
|
||||
videoPath,
|
||||
None,
|
||||
playbackSettings.VideoCodec)
|
||||
playbackSettings.VideoFormat)
|
||||
.WithOutputFormat("apng", outputFile)
|
||||
.Build();
|
||||
|
||||
@@ -404,8 +270,10 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
"ffmpeg song arguments {FFmpegArguments}",
|
||||
string.Join(" ", process.StartInfo.ArgumentList));
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
await Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(cancellationToken);
|
||||
|
||||
return outputFile;
|
||||
}
|
||||
@@ -427,8 +295,7 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
Option<ChannelWatermark> watermarkOverride,
|
||||
Option<string> watermarkPath)
|
||||
{
|
||||
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
|
||||
channel.FFmpegProfile.NormalizeVideo)
|
||||
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
{
|
||||
if (videoVersion is CoverArtMediaVersion)
|
||||
{
|
||||
|
||||
@@ -30,7 +30,8 @@ public class SongVideoGenerator : ISongVideoGenerator
|
||||
Song song,
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> maybeGlobalWatermark,
|
||||
string ffmpegPath)
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> subtitleFile = None;
|
||||
|
||||
@@ -224,7 +225,8 @@ public class SongVideoGenerator : ISongVideoGenerator
|
||||
watermarkLocation,
|
||||
HORIZONTAL_MARGIN_PERCENT,
|
||||
VERTICAL_MARGIN_PERCENT,
|
||||
WATERMARK_WIDTH_PERCENT);
|
||||
WATERMARK_WIDTH_PERCENT,
|
||||
cancellationToken);
|
||||
|
||||
foreach (string si in maybeSongImage.RightToSeq())
|
||||
{
|
||||
|
||||
@@ -7,5 +7,6 @@ public enum TempFileCategory
|
||||
CoverArt = 2,
|
||||
CachedArtwork = 3,
|
||||
|
||||
BadTranscodeFolder = 98,
|
||||
BadPlaylist = 99
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public enum VaapiDriver
|
||||
Default = 0,
|
||||
iHD = 1,
|
||||
i965 = 2,
|
||||
RadeonSI = 3
|
||||
RadeonSI = 3,
|
||||
Nouveau = 4
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
public interface IHealthCheck
|
||||
{
|
||||
Task<HealthCheckResult> Check();
|
||||
Task<HealthCheckResult> Check(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
public interface IHealthCheckService
|
||||
{
|
||||
Task<List<HealthCheckResult>> PerformHealthChecks();
|
||||
Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface IEmbyMovieLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface IEmbyTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -57,5 +57,6 @@ public interface IFFmpegProcessService
|
||||
WatermarkLocation watermarkLocation,
|
||||
int horizontalMarginPercent,
|
||||
int verticalMarginPercent,
|
||||
int watermarkWidthPercent);
|
||||
int watermarkWidthPercent,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface ISongVideoGenerator
|
||||
Song song,
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> maybeGlobalWatermark,
|
||||
string ffmpegPath);
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface IJellyfinMovieLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface IJellyfinTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -4,8 +4,13 @@ namespace ErsatzTV.Core.Interfaces.Metadata;
|
||||
|
||||
public interface ILocalStatisticsProvider
|
||||
{
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem, string mediaItemPath);
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
|
||||
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
MediaItem mediaItem,
|
||||
string mediaItemPath);
|
||||
|
||||
Task<Either<BaseError, Dictionary<string, string>>> GetFormatTags(string ffprobePath, MediaItem mediaItem);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ public interface IMovieFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ public interface IMusicVideoFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ public interface IOtherVideoFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
|
||||
@@ -9,5 +9,6 @@ public interface ISongFolderScanner
|
||||
string ffprobePath,
|
||||
string ffmpegPath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ public interface ITelevisionFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -9,5 +9,6 @@ public interface IPlexMovieLibraryScanner
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -9,5 +9,6 @@ public interface IPlexTelevisionLibraryScanner
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -44,8 +44,8 @@ public class ChannelPlaylist
|
||||
_ => "ts?mode=ts-legacy"
|
||||
};
|
||||
|
||||
string vcodec = channel.FFmpegProfile.VideoCodec.Split("_").Head();
|
||||
string acodec = channel.FFmpegProfile.AudioCodec;
|
||||
string vcodec = channel.FFmpegProfile.VideoFormat.ToString().ToLowerInvariant();
|
||||
string acodec = channel.FFmpegProfile.AudioFormat.ToString().ToLowerInvariant();
|
||||
|
||||
sb.AppendLine(
|
||||
$"#EXTINF:0 tvg-id=\"{channel.Number}.etv\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"{channel.Group}\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
|
||||
|
||||
@@ -51,6 +51,7 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<JellyfinItemEtag> existingMovies = await _movieRepository.GetExistingJellyfinMovies(library);
|
||||
@@ -172,7 +173,11 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
incomingMovie,
|
||||
localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -51,6 +51,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
|
||||
@@ -70,7 +71,15 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
await maybeShows.Match(
|
||||
async shows =>
|
||||
{
|
||||
await ProcessShows(address, apiKey, library, ffprobePath, pathReplacements, existingShows, shows);
|
||||
await ProcessShows(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
existingShows,
|
||||
shows);
|
||||
|
||||
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
|
||||
var showIds = existingShows
|
||||
@@ -104,6 +113,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
List<JellyfinItemEtag> existingShows,
|
||||
@@ -167,6 +177,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
@@ -196,6 +207,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
JellyfinShow show,
|
||||
@@ -286,6 +298,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
@@ -319,6 +332,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
string showName,
|
||||
string seasonName,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
JellyfinSeason season,
|
||||
@@ -408,7 +422,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, incomingEpisode, localPath);
|
||||
|
||||
refreshResult.Match(
|
||||
_ => { },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -79,6 +80,7 @@ public abstract class LocalFolderScanner
|
||||
|
||||
protected async Task<Either<BaseError, MediaItemScanResult<T>>> UpdateStatistics<T>(
|
||||
MediaItemScanResult<T> mediaItem,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
where T : MediaItem
|
||||
{
|
||||
@@ -92,7 +94,7 @@ public abstract class LocalFolderScanner
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem.Item);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, mediaItem.Item);
|
||||
refreshResult.Match(
|
||||
result =>
|
||||
{
|
||||
@@ -123,7 +125,8 @@ public abstract class LocalFolderScanner
|
||||
Domain.Metadata metadata,
|
||||
ArtworkKind artworkKind,
|
||||
Option<string> ffmpegPath,
|
||||
Option<int> attachedPicIndex)
|
||||
Option<int> attachedPicIndex,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
|
||||
|
||||
@@ -167,8 +170,11 @@ public abstract class LocalFolderScanner
|
||||
artworkFile,
|
||||
picIndex,
|
||||
tempName);
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
await Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(cancellationToken);
|
||||
|
||||
return tempName;
|
||||
},
|
||||
@@ -177,8 +183,11 @@ public abstract class LocalFolderScanner
|
||||
// no attached pic index means convert to png
|
||||
string tempName = _tempFilePool.GetNextTempFile(TempFileCategory.CoverArt);
|
||||
using Process process = ffmpegProcessService.ConvertToPng(path, artworkFile, tempName);
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
await Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(cancellationToken);
|
||||
|
||||
return tempName;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
@@ -30,12 +31,12 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
|
||||
public async Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
|
||||
return await RefreshStatistics(ffprobePath, mediaItem, filePath);
|
||||
return await RefreshStatistics(ffmpegPath, ffprobePath, mediaItem, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -46,6 +47,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, bool>> RefreshStatistics(
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
MediaItem mediaItem,
|
||||
string mediaItemPath)
|
||||
@@ -57,6 +59,11 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
async ffprobe =>
|
||||
{
|
||||
MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe);
|
||||
if (version.Duration.TotalSeconds < 1)
|
||||
{
|
||||
await AnalyzeDuration(ffmpegPath, mediaItemPath, version);
|
||||
}
|
||||
|
||||
bool result = await ApplyVersionUpdate(mediaItem, version, mediaItemPath);
|
||||
return Right<BaseError, bool>(result);
|
||||
},
|
||||
@@ -185,6 +192,67 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
});
|
||||
}
|
||||
|
||||
private async Task AnalyzeDuration(string ffmpegPath, string path, MediaVersion version)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Media item at {Path} is missing duration metadata and requires additional analysis",
|
||||
path);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(path);
|
||||
startInfo.ArgumentList.Add("-f");
|
||||
startInfo.ArgumentList.Add("null");
|
||||
startInfo.ArgumentList.Add("-");
|
||||
|
||||
var probe = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
probe.Start();
|
||||
string output = await probe.StandardError.ReadToEndAsync();
|
||||
await probe.WaitForExitAsync();
|
||||
if (probe.ExitCode == 0)
|
||||
{
|
||||
const string PATTERN = @"time=([^ ]+)";
|
||||
IEnumerable<string> reversed = output.Split("\n").Reverse();
|
||||
foreach (string line in reversed)
|
||||
{
|
||||
Match match = Regex.Match(line, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
string time = match.Groups[1].Value;
|
||||
var duration = TimeSpan.Parse(time, NumberFormatInfo.InvariantInfo);
|
||||
_logger.LogInformation("Analyzed duration is {Duration}", duration);
|
||||
version.Duration = duration;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Duration analysis failed for media item at {Path}", path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
_logger.LogError("Duration analysis failed for media item at {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
internal MediaVersion ProjectToMediaVersion(string path, FFprobe probeOutput) =>
|
||||
Optional(probeOutput)
|
||||
.Filter(json => json?.format != null && json.streams != null)
|
||||
@@ -210,13 +278,13 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
var seconds = TimeSpan.FromSeconds(duration);
|
||||
version.Duration = seconds;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Media item at {Path} has a missing or invalid duration {Duration} and will cause scheduling issues",
|
||||
path,
|
||||
json.format.duration);
|
||||
}
|
||||
// else
|
||||
// {
|
||||
// _logger.LogWarning(
|
||||
// "Media item at {Path} has a missing or invalid duration {Duration} and will cause scheduling issues",
|
||||
// path,
|
||||
// json.format.duration);
|
||||
// }
|
||||
|
||||
foreach (FFprobeStream audioStream in json.streams.Filter(s => s.codec_type == "audio"))
|
||||
{
|
||||
|
||||
@@ -64,9 +64,11 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
@@ -131,10 +133,10 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath))
|
||||
.BindT(movie => UpdateStatistics(movie, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken))
|
||||
.BindT(FlagNormal);
|
||||
|
||||
await maybeMovie.Match(
|
||||
@@ -228,7 +230,8 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateArtwork(
|
||||
MediaItemScanResult<Movie> result,
|
||||
ArtworkKind artworkKind)
|
||||
ArtworkKind artworkKind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -237,7 +240,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind, None, None);
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -65,9 +65,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
@@ -87,17 +89,19 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
|
||||
await FindOrCreateArtist(libraryPath.Id, artistFolder)
|
||||
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
|
||||
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail))
|
||||
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt));
|
||||
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail, cancellationToken))
|
||||
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken));
|
||||
|
||||
await maybeArtist.Match(
|
||||
async result =>
|
||||
{
|
||||
await ScanMusicVideos(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
result.Item,
|
||||
artistFolder);
|
||||
artistFolder,
|
||||
cancellationToken);
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -210,7 +214,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> UpdateArtworkForArtist(
|
||||
MediaItemScanResult<Artist> result,
|
||||
string artistFolder,
|
||||
ArtworkKind artworkKind)
|
||||
ArtworkKind artworkKind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -219,7 +224,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
async artworkFile =>
|
||||
{
|
||||
ArtistMetadata metadata = artist.ArtistMetadata.Head();
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -233,9 +238,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
|
||||
private async Task ScanMusicVideos(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
Artist artist,
|
||||
string artistFolder)
|
||||
string artistFolder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var folderQueue = new Queue<string>();
|
||||
folderQueue.Enqueue(artistFolder);
|
||||
@@ -272,9 +279,9 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
// TODO: figure out how to rebuild playouts
|
||||
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo = await _musicVideoRepository
|
||||
.GetOrAdd(artist, libraryPath, file)
|
||||
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
|
||||
.BindT(musicVideo => UpdateStatistics(musicVideo, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail)
|
||||
.BindT(result => UpdateThumbnail(result, cancellationToken))
|
||||
.BindT(FlagNormal);
|
||||
|
||||
await maybeMusicVideo.Match(
|
||||
@@ -376,7 +383,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateThumbnail(
|
||||
MediaItemScanResult<MusicVideo> result)
|
||||
MediaItemScanResult<MusicVideo> result,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -385,7 +393,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
async thumbnailFile =>
|
||||
{
|
||||
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None);
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -62,6 +62,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
@@ -126,7 +127,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
{
|
||||
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffprobePath))
|
||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(FlagNormal);
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
string ffprobePath,
|
||||
string ffmpegPath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
@@ -128,9 +129,9 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
{
|
||||
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffprobePath))
|
||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||
.BindT(video => UpdateMetadata(video, ffprobePath))
|
||||
.BindT(video => UpdateThumbnail(video, ffmpegPath))
|
||||
.BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken))
|
||||
.BindT(FlagNormal);
|
||||
|
||||
await maybeSong.Match(
|
||||
@@ -212,7 +213,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail(
|
||||
MediaItemScanResult<Song> result,
|
||||
string ffmpegPath)
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -234,10 +236,16 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
async thumbnailFile =>
|
||||
{
|
||||
SongMetadata metadata = song.SongMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, ffmpegPath, None);
|
||||
await RefreshArtwork(
|
||||
thumbnailFile,
|
||||
metadata,
|
||||
ArtworkKind.Thumbnail,
|
||||
ffmpegPath,
|
||||
None,
|
||||
cancellationToken);
|
||||
},
|
||||
() => ExtractEmbeddedArtwork(song, ffmpegPath));
|
||||
|
||||
() => ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken));
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -246,7 +254,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private Option<string> LocateThumbnail(Song song)
|
||||
{
|
||||
string path = song.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
@@ -263,7 +271,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
}).Flatten();
|
||||
}
|
||||
|
||||
private async Task ExtractEmbeddedArtwork(Song song, string ffmpegPath)
|
||||
private async Task ExtractEmbeddedArtwork(Song song, string ffmpegPath, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<MediaStream> maybeArtworkStream = Optional(song.GetHeadVersion().Streams.Find(ms => ms.AttachedPic));
|
||||
foreach (MediaStream artworkStream in maybeArtworkStream)
|
||||
@@ -273,7 +281,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
song.SongMetadata.Head(),
|
||||
ArtworkKind.Thumbnail,
|
||||
ffmpegPath,
|
||||
artworkStream.Index);
|
||||
artworkStream.Index,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
@@ -84,18 +86,20 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail));
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster, cancellationToken))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt, cancellationToken))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken));
|
||||
|
||||
await maybeShow.Match(
|
||||
async result =>
|
||||
{
|
||||
await ScanSeasons(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
result.Item,
|
||||
showFolder);
|
||||
showFolder,
|
||||
cancellationToken);
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -154,9 +158,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
private async Task<Unit> ScanSeasons(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
Show show,
|
||||
string showFolder)
|
||||
string showFolder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
@@ -179,12 +185,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
Either<BaseError, Season> maybeSeason = await _televisionRepository
|
||||
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
|
||||
.BindT(EnsureMetadataExists)
|
||||
.BindT(season => UpdatePoster(season, seasonFolder));
|
||||
.BindT(season => UpdatePoster(season, seasonFolder, cancellationToken));
|
||||
|
||||
await maybeSeason.Match(
|
||||
async season =>
|
||||
{
|
||||
await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder);
|
||||
await ScanEpisodes(libraryPath, ffmpegPath, ffprobePath, season, seasonFolder, cancellationToken);
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
|
||||
|
||||
season.Show = show;
|
||||
@@ -206,9 +212,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
private async Task<Unit> ScanEpisodes(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
Season season,
|
||||
string seasonPath)
|
||||
string seasonPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allSeasonFiles = _localFileSystem.ListSubdirectories(seasonPath)
|
||||
.Map(_localFileSystem.ListFiles)
|
||||
@@ -225,10 +233,10 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
|
||||
.GetOrAddEpisode(season, libraryPath, file)
|
||||
.BindT(
|
||||
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
|
||||
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffmpegPath, ffprobePath)
|
||||
.MapT(_ => episode))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail)
|
||||
.BindT(e => UpdateThumbnail(e, cancellationToken))
|
||||
.BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e)))
|
||||
.MapT(r => r.Item);
|
||||
|
||||
@@ -359,7 +367,8 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateArtworkForShow(
|
||||
MediaItemScanResult<Show> result,
|
||||
string showFolder,
|
||||
ArtworkKind artworkKind)
|
||||
ArtworkKind artworkKind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -368,7 +377,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
async artworkFile =>
|
||||
{
|
||||
ShowMetadata metadata = show.ShowMetadata.Head();
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -380,7 +389,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Season>> UpdatePoster(Season season, string seasonFolder)
|
||||
private async Task<Either<BaseError, Season>> UpdatePoster(Season season, string seasonFolder, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -388,7 +397,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
async posterFile =>
|
||||
{
|
||||
SeasonMetadata metadata = season.SeasonMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None, None);
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return season;
|
||||
@@ -400,7 +409,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Episode>> UpdateThumbnail(Episode episode)
|
||||
private async Task<Either<BaseError, Episode>> UpdateThumbnail(Episode episode, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -409,7 +418,13 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
{
|
||||
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
|
||||
{
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail, None, None);
|
||||
await RefreshArtwork(
|
||||
posterFile,
|
||||
metadata,
|
||||
ArtworkKind.Thumbnail,
|
||||
None,
|
||||
None,
|
||||
cancellationToken);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
@@ -93,7 +94,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
.BindT(existing => UpdateStatistics(pathReplacements, existing, incoming, ffprobePath))
|
||||
.BindT(
|
||||
existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath))
|
||||
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
@@ -145,6 +147,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
@@ -178,7 +181,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -55,6 +55,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
@@ -82,7 +83,14 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
await maybeShow.Match(
|
||||
async result =>
|
||||
{
|
||||
await ScanSeasons(library, pathReplacements, result.Item, connection, token, ffprobePath);
|
||||
await ScanSeasons(
|
||||
library,
|
||||
pathReplacements,
|
||||
result.Item,
|
||||
connection,
|
||||
token,
|
||||
ffmpegPath,
|
||||
ffprobePath);
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -286,6 +294,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
|
||||
@@ -315,6 +324,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
season,
|
||||
connection,
|
||||
token,
|
||||
ffmpegPath,
|
||||
ffprobePath);
|
||||
|
||||
season.Show = show;
|
||||
@@ -384,6 +394,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
|
||||
@@ -431,6 +442,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
library,
|
||||
connection,
|
||||
token,
|
||||
ffmpegPath,
|
||||
ffprobePath))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
@@ -503,6 +515,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
@@ -535,7 +548,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.5.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="Moq" Version="4.17.1" />
|
||||
<PackageReference Include="Moq" Version="4.17.2" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
|
||||
@@ -93,6 +93,68 @@ public class PipelineGeneratorTests
|
||||
"-threads 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 -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");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsDirect_Test()
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<VideoStream>
|
||||
{ new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) });
|
||||
|
||||
var audioInputFile = new AudioInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
|
||||
new AudioState(
|
||||
AudioFormat.Copy,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false));
|
||||
|
||||
var desiredState = new FrameState(
|
||||
true,
|
||||
false,
|
||||
VideoFormat.Copy,
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(1920, 1080),
|
||||
new FrameSize(1920, 1080),
|
||||
Option<int>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<TimeSpan>.None,
|
||||
Option<TimeSpan>.None,
|
||||
false,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
OutputFormatKind.MpegTs,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
0);
|
||||
|
||||
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, "", _logger);
|
||||
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
|
||||
|
||||
result.PipelineSteps.Should().HaveCountGreaterThan(0);
|
||||
result.PipelineSteps.Should().Contain(ps => ps is EncoderCopyVideo);
|
||||
result.PipelineSteps.Should().Contain(ps => ps is EncoderCopyAudio);
|
||||
|
||||
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
|
||||
|
||||
command.Should().Be(
|
||||
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
|
||||
}
|
||||
|
||||
private static string PrintCommand(
|
||||
Option<VideoInputFile> videoInputFile,
|
||||
Option<AudioInputFile> audioInputFile,
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.2" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -607,6 +607,11 @@ public class PipelineBuilder
|
||||
|
||||
private static bool IsDesiredVideoState(FrameState currentState, FrameState desiredState)
|
||||
{
|
||||
if (desiredState.VideoFormat == VideoFormat.Copy)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentState.VideoFormat == desiredState.VideoFormat &&
|
||||
currentState.PixelFormat.Match(pf => pf.Name, () => string.Empty) ==
|
||||
desiredState.PixelFormat.Match(pf => pf.Name, string.Empty) &&
|
||||
|
||||
@@ -12,5 +12,8 @@ public class FFmpegProfileConfiguration : IEntityTypeConfiguration<FFmpegProfile
|
||||
|
||||
builder.Property(p => p.NormalizeFramerate)
|
||||
.HasDefaultValue(false);
|
||||
|
||||
builder.Property(p => p.DeinterlaceVideo)
|
||||
.HasDefaultValue(true);
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,17 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blurhash.ImageSharp" Version="1.1.1" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.2" />
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.3">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.3" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using ErsatzTV.Core.Health;
|
||||
using Lucene.Net.Util;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Health.Checks;
|
||||
|
||||
@@ -28,27 +28,17 @@ public abstract class BaseHealthCheck
|
||||
|
||||
protected HealthCheckResult InfoResult(string message) =>
|
||||
new(Title, HealthCheckStatus.Info, message, None);
|
||||
|
||||
protected static async Task<string> GetProcessOutput(string path, IEnumerable<string> arguments)
|
||||
|
||||
protected static async Task<string> GetProcessOutput(
|
||||
string path,
|
||||
IEnumerable<string> arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
BufferedCommandResult result = await Cli.Wrap(path)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteBufferedAsync(cancellationToken);
|
||||
|
||||
startInfo.ArgumentList.AddRange(arguments);
|
||||
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
process.Start();
|
||||
string result = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
return result;
|
||||
return result.StandardOutput;
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,16 @@ public class EpisodeMetadataHealthCheck : BaseHealthCheck, IEpisodeMetadataHealt
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
protected override string Title => "Episode Metadata";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Episode> episodes = await dbContext.Episodes
|
||||
.Filter(e => e.EpisodeMetadata.Count == 0)
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (episodes.Any())
|
||||
{
|
||||
@@ -36,7 +36,8 @@ public class EpisodeMetadataHealthCheck : BaseHealthCheck, IEpisodeMetadataHealt
|
||||
|
||||
var folders = string.Join(", ", paths);
|
||||
|
||||
return WarningResult($"There are {episodes.Count} episodes with missing metadata, including in the following folders: {folders}");
|
||||
return WarningResult(
|
||||
$"There are {episodes.Count} episodes with missing metadata, including in the following folders: {folders}");
|
||||
}
|
||||
|
||||
return OkResult();
|
||||
|
||||
@@ -16,7 +16,7 @@ public class ErrorReportsHealthCheck : BaseHealthCheck, IErrorReportsHealthCheck
|
||||
|
||||
protected override string Title => "Error Reports";
|
||||
|
||||
public Task<HealthCheckResult> Check()
|
||||
public Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_bugsnagConfiguration.Value.Enable)
|
||||
{
|
||||
@@ -26,6 +26,7 @@ public class ErrorReportsHealthCheck : BaseHealthCheck, IErrorReportsHealthCheck
|
||||
.AsTask();
|
||||
}
|
||||
|
||||
return InfoResult("Automated error reporting is disabled. Please enable to support bug fixing efforts!").AsTask();
|
||||
return InfoResult("Automated error reporting is disabled. Please enable to support bug fixing efforts!")
|
||||
.AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class FFmpegReportsHealthCheck : BaseHealthCheck, IFFmpegReportsHealthChe
|
||||
public FFmpegReportsHealthCheck(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
Option<bool> saveReports =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
|
||||
|
||||
@@ -16,7 +16,7 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ConfigElement> maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
|
||||
if (maybeFFmpegPath.IsNone)
|
||||
@@ -29,9 +29,10 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
{
|
||||
return FailResult("Unable to locate ffprobe");
|
||||
}
|
||||
|
||||
foreach (ConfigElement ffmpegPath in maybeFFmpegPath)
|
||||
{
|
||||
Option<string> maybeVersion = await GetVersion(ffmpegPath.Value);
|
||||
Option<string> maybeVersion = await GetVersion(ffmpegPath.Value, cancellationToken);
|
||||
if (maybeVersion.IsNone)
|
||||
{
|
||||
return WarningResult("Unable to determine ffmpeg version");
|
||||
@@ -48,7 +49,7 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
|
||||
foreach (ConfigElement ffprobePath in maybeFFprobePath)
|
||||
{
|
||||
Option<string> maybeVersion = await GetVersion(ffprobePath.Value);
|
||||
Option<string> maybeVersion = await GetVersion(ffprobePath.Value, cancellationToken);
|
||||
if (maybeVersion.IsNone)
|
||||
{
|
||||
return WarningResult("Unable to determine ffprobe version");
|
||||
@@ -65,7 +66,7 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
|
||||
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty, None);
|
||||
}
|
||||
|
||||
|
||||
private Option<HealthCheckResult> ValidateVersion(string version, string app)
|
||||
{
|
||||
if (version.StartsWith("3.") || version.StartsWith("4."))
|
||||
@@ -82,9 +83,9 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
return None;
|
||||
}
|
||||
|
||||
private static async Task<Option<string>> GetVersion(string path)
|
||||
private static async Task<Option<string>> GetVersion(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeLine = await GetProcessOutput(path, new[] { "-version" })
|
||||
Option<string> maybeLine = await GetProcessOutput(path, new[] { "-version" }, cancellationToken)
|
||||
.Map(s => s.Split("\n").HeadOrNone().Map(h => h.Trim()));
|
||||
foreach (string line in maybeLine)
|
||||
{
|
||||
|
||||
@@ -16,58 +16,58 @@ public class FileNotFoundHealthCheck : BaseHealthCheck, IFileNotFoundHealthCheck
|
||||
|
||||
protected override string Title => "File Not Found";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Episode> episodes = await dbContext.Episodes
|
||||
.Filter(e => e.State == MediaItemState.FileNotFound)
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
List<Episode> episodes = await dbContext.Episodes
|
||||
.Filter(e => e.State == MediaItemState.FileNotFound)
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<Movie> movies = await dbContext.Movies
|
||||
.Filter(m => m.State == MediaItemState.FileNotFound)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
List<MusicVideo> musicVideos = await dbContext.MusicVideos
|
||||
.Filter(mv => mv.State == MediaItemState.FileNotFound)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
List<OtherVideo> otherVideos = await dbContext.OtherVideos
|
||||
.Filter(ov => ov.State == MediaItemState.FileNotFound)
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
List<Movie> movies = await dbContext.Movies
|
||||
.Filter(m => m.State == MediaItemState.FileNotFound)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<Song> songs = await dbContext.Songs
|
||||
.Filter(s => s.State == MediaItemState.FileNotFound)
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
var all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
|
||||
.Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path))
|
||||
.Append(musicVideos.Map(mv => mv.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.Append(otherVideos.Map(ov => ov.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.Append(songs.Map(s => s.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.ToList();
|
||||
List<MusicVideo> musicVideos = await dbContext.MusicVideos
|
||||
.Filter(mv => mv.State == MediaItemState.FileNotFound)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (all.Any())
|
||||
{
|
||||
var paths = all.Take(5).ToList();
|
||||
List<OtherVideo> otherVideos = await dbContext.OtherVideos
|
||||
.Filter(ov => ov.State == MediaItemState.FileNotFound)
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var files = string.Join(", ", paths);
|
||||
List<Song> songs = await dbContext.Songs
|
||||
.Filter(s => s.State == MediaItemState.FileNotFound)
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return WarningResult(
|
||||
$"There are {all.Count} files that do not exist on disk, including the following: {files}",
|
||||
"/media/trash");
|
||||
}
|
||||
var all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
|
||||
.Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path))
|
||||
.Append(musicVideos.Map(mv => mv.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.Append(otherVideos.Map(ov => ov.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.Append(songs.Map(s => s.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.ToList();
|
||||
|
||||
return OkResult();
|
||||
if (all.Any())
|
||||
{
|
||||
var paths = all.Take(5).ToList();
|
||||
|
||||
var files = string.Join(", ", paths);
|
||||
|
||||
return WarningResult(
|
||||
$"There are {all.Count} files that do not exist on disk, including the following: {files}",
|
||||
"/media/trash");
|
||||
}
|
||||
|
||||
return OkResult();
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ConfigElement> maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
|
||||
if (maybeFFmpegPath.IsNone)
|
||||
@@ -49,7 +49,8 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
|
||||
|
||||
if (!accelerationKinds.Any())
|
||||
{
|
||||
accelerationKinds.AddRange(await GetSupportedAccelerationKinds(maybeFFmpegPath.ValueUnsafe().Value));
|
||||
accelerationKinds.AddRange(
|
||||
await GetSupportedAccelerationKinds(maybeFFmpegPath.ValueUnsafe().Value, cancellationToken));
|
||||
}
|
||||
|
||||
if (!accelerationKinds.Any())
|
||||
@@ -69,7 +70,7 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
|
||||
private async Task<Option<HealthCheckResult>> VerifyProfilesUseAcceleration(
|
||||
IEnumerable<HardwareAccelerationKind> accelerationKinds)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<Channel> badChannels = await dbContext.Channels
|
||||
.Filter(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
@@ -87,11 +88,13 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
|
||||
return None;
|
||||
}
|
||||
|
||||
private static async Task<List<HardwareAccelerationKind>> GetSupportedAccelerationKinds(string ffmpegPath)
|
||||
private static async Task<List<HardwareAccelerationKind>> GetSupportedAccelerationKinds(
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new System.Collections.Generic.HashSet<HardwareAccelerationKind>();
|
||||
|
||||
string output = await GetProcessOutput(ffmpegPath, new[] { "-v", "quiet", "-hwaccels" });
|
||||
|
||||
string output = await GetProcessOutput(ffmpegPath, new[] { "-v", "quiet", "-hwaccels" }, cancellationToken);
|
||||
foreach (string method in output.Split("\n").Map(s => s.Trim()).Skip(1))
|
||||
{
|
||||
switch (method)
|
||||
|
||||
@@ -14,16 +14,16 @@ public class MovieMetadataHealthCheck : BaseHealthCheck, IMovieMetadataHealthChe
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
protected override string Title => "Movie Metadata";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Movie> movies = await dbContext.Movies
|
||||
.Filter(e => e.MovieMetadata.Count == 0)
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (movies.Any())
|
||||
{
|
||||
@@ -36,7 +36,8 @@ public class MovieMetadataHealthCheck : BaseHealthCheck, IMovieMetadataHealthChe
|
||||
|
||||
var folders = string.Join(", ", paths);
|
||||
|
||||
return WarningResult($"There are {movies.Count} movies with missing metadata, including in the following folders: {folders}");
|
||||
return WarningResult(
|
||||
$"There are {movies.Count} movies with missing metadata, including in the following folders: {folders}");
|
||||
}
|
||||
|
||||
return OkResult();
|
||||
|
||||
@@ -18,13 +18,13 @@ public class VaapiDriverHealthCheck : BaseHealthCheck, IVaapiDriverHealthCheck
|
||||
|
||||
protected override string Title => "VAAPI Driver";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FFmpegProfile> profiles = await dbContext.FFmpegProfiles
|
||||
.Filter(p => p.HardwareAcceleration == HardwareAccelerationKind.Vaapi)
|
||||
.ToListAsync();
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (profiles.Count == 0)
|
||||
{
|
||||
return NotApplicableResult();
|
||||
|
||||
@@ -16,40 +16,40 @@ public class ZeroDurationHealthCheck : BaseHealthCheck, IZeroDurationHealthCheck
|
||||
|
||||
protected override string Title => "Zero Duration";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Episode> episodes = await dbContext.Episodes
|
||||
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<Movie> movies = await dbContext.Movies
|
||||
.Filter(m => m.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<MusicVideo> musicVideos = await dbContext.MusicVideos
|
||||
.Filter(mv => mv.MediaVersions.Any(v => v.Duration == TimeSpan.Zero))
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<OtherVideo> otherVideos = await dbContext.OtherVideos
|
||||
.Filter(ov => ov.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<Song> songs = await dbContext.Songs
|
||||
.Filter(s => s.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
|
||||
.Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path))
|
||||
.Append(musicVideos.Map(mv => mv.GetHeadVersion().MediaFiles.Head().Path))
|
||||
|
||||
@@ -33,6 +33,6 @@ public class HealthCheckService : IHealthCheckService
|
||||
};
|
||||
}
|
||||
|
||||
public Task<List<HealthCheckResult>> PerformHealthChecks() =>
|
||||
_checks.Map(c => c.Check()).SequenceParallel().Map(results => results.ToList());
|
||||
public Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken) =>
|
||||
_checks.Map(c => c.Check(cancellationToken)).SequenceParallel().Map(results => results.ToList());
|
||||
}
|
||||
+3886
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Analyze_ZeroDurationFiles : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
|
||||
(
|
||||
SELECT LF.Id FROM LibraryFolder LF
|
||||
INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id
|
||||
INNER JOIN Library L on LP.LibraryId = L.Id
|
||||
INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
INNER JOIN MediaVersion MV on MI.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
|
||||
WHERE MV.Duration = '00:00:00.0000000'
|
||||
)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
|
||||
(
|
||||
SELECT LP.Id FROM LibraryPath LP
|
||||
INNER JOIN Library L on LP.LibraryId = L.Id
|
||||
INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
INNER JOIN MediaVersion MV on MI.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
|
||||
WHERE MV.Duration = '00:00:00.0000000'
|
||||
)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
|
||||
(
|
||||
SELECT L.Id FROM Library L
|
||||
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
|
||||
INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
INNER JOIN MediaVersion MV on MI.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
|
||||
WHERE MV.Duration = '00:00:00.0000000'
|
||||
)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00' WHERE Duration = '00:00:00.0000000'");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user