Compare commits

...
Author SHA1 Message Date
Jason Dove 0308106c1b update changelog for release v0.4.5-alpha [no ci] 2022-03-29 20:12:01 -05:00
Jason DoveandGitHub ba93e3eeea always check for plex on the localhost (#716) 2022-03-29 19:39:55 -05:00
Jason DoveandGitHub aa2c914d8a always transcode and normalize, except with HLS Direct (#715)
* remove transcode, normalize video, normalize audio settings

* cleanup

* update changelog
2022-03-28 18:25:33 -05:00
Jason Dove caa9bf82d5 update changelog [no ci] 2022-03-28 13:10:33 -05:00
Jason Dove e397035c5a update changelog [no ci] 2022-03-18 14:07:15 -05:00
Jason DoveandGitHub d32f881c4e use cliwrap for windows wrapper (#710) 2022-03-18 13:36:38 -05:00
Jason DoveandGitHub 9b3c24559d add deinterlace option to ffmpeg profile (#709)
* update dependencies

* add option to deinterlace video
2022-03-18 10:30:56 -05:00
Jason Dove 7c75b169ec try gon again 2022-03-16 11:39:44 -05:00
Jason Dove 4f1952340f fix gon since brew is not working 2022-03-16 11:24:52 -05:00
Jason Dove ac2de24f6e fix build 2022-03-16 10:50:38 -05:00
Jason DoveandGitHub 4b9781dad4 add v2 channels table (#708)
* change mock api port; fix cors

* fix version request

* accept base url env var

* tweak colors

* fix version service

* add basic channels list

* fix auto page title for ts classes

* add GET /api/channels
2022-03-16 10:28:54 -05:00
Jason DoveandGitHub d88c179b63 axios test (#705)
* add version controller

* use axios to get version

* allow typescript

* use version api service
2022-03-15 17:55:05 -05:00
Jason DoveandGitHub 809a623a95 vue formatting tweaks (#704)
* add .prettierrc

* apply formatting
2022-03-14 10:59:04 -05:00
Jason Dove b453dce57e try npm ci with cache 2022-03-13 20:38:04 -05:00
Jason Dove edd31755c0 try npm install again 2022-03-13 20:21:16 -05:00
Jason DoveandGitHub 7de1a87bbf bug fixes (#703)
* catch expected shutdown error in scheduler service

* fix streaming mode inconsistencies
2022-03-13 18:45:21 -05:00
Jason DoveandGitHub 5731edc82e v2 ui tweaks (#702)
* fix m3u and xml urls (kind of)

* use svg logos

* tweak theme colors
2022-03-13 12:44:35 -05:00
Jason DoveandGitHub 2f668e53dd fix npm build (#701) 2022-03-13 11:11:08 -05:00
Jason DoveandGitHub abd223acd2 fix docker builds (#700) 2022-03-13 10:52:03 -05:00
6a9075dc11 add vue ui at /v2 (#698)
* add vue ui

* add channels mock api

* Initial Vue framework with Vuetify UI (#688)

* fix hls direct streaming mode (#682)

* duration analysis on files with missing duration metadata (#683)

* first pass

* analyze zero-duration files

* add readme note for WIP

* add vuetify and basic sidebar layout - responsive

* add vue-router and initial home page

* setup composition-api for vue2

* install pinia ie... vuex4

* mixing for automatic page title

* add logo files

* tweak theme colors

* install store

* use store for menu toggle

* replicate old menu

* implement menu and children menus

* rename state to application state

* update vue files and add version to sidebar

* lock logo and make expandable list remove minified menu

* remove todo, will add to PR

* top bar links and attempt at snackbar with state

* fix snackbar

* add search basic component

* fix search bar placement

* remove un-used footer

* Revert "Merge branch 'jasongdove:main' into intitial-vuetify-ui"

This reverts commit 43016d502b.

Co-authored-by: Jason Dove <jason@jasondove.me>

* Add ESLint and Prettier for VueJS (#691)

* add prettier to config

* run npm run lint over project to clean up files

* replace hr tag with v-dividers

* add vue-lint github action

* fix the dodo in me

* Hu

* Fix path

* convert to multi-run step

* forgot the name

* add vue-lint github action

fix the dodo in me

Hu

Fix path

convert to multi-run step

forgot the name

* Fix new line at end of file

* WIP

* dockerfile consistency

* use npm ci and node v14

* force prettier indenting and end of line (#695)

* disable filename hashing

* don't build tests in docker

* update dependencies

* fix running both uis

Co-authored-by: James Mackay <info@notexpectedyet.com>
2022-03-12 14:15:31 -06:00
Jason DoveandGitHub d5a03963c0 use video and audio format instead of video and audio codec (#696) 2022-03-11 19:58:05 -06:00
Jason Dove f3e5ff198b update changelog for release v0.4.4-alpha [no ci] 2022-03-10 17:54:39 -06:00
Jason DoveandGitHub 6be5111195 fix errors; add nouveau vaapi driver option (#692)
* fix service shutdown errors

* add nouveau vaapi driver option
2022-03-10 12:19:30 -06:00
Jason DoveandGitHub f0670b345f use wrapped processes; fix hls pts bug (#690) 2022-03-09 14:38:09 -06:00
Jason DoveandGitHub 6a1c2b7659 read ffprobe pts output from stdout and stderr (#689) 2022-03-09 08:54:51 -06:00
Jason DoveandGitHub 7cd2f9a56f update dependencies (#687) 2022-03-08 20:21:10 -06:00
Jason DoveandGitHub f66bc783a7 fix hls segmenter on windows (#686) 2022-03-08 19:58:41 -06:00
Jason DoveandGitHub bc225d35fa add troubleshooting code to hls segmenter (#685) 2022-03-08 12:52:29 -06:00
Jason DoveandGitHub 52a8b7db81 duration analysis on files with missing duration metadata (#683)
* first pass

* analyze zero-duration files
2022-03-07 21:42:34 -06:00
Jason DoveandGitHub dcd792a354 fix hls direct streaming mode (#682) 2022-03-07 20:04:21 -06:00
170 changed files with 52467 additions and 1566 deletions
+23 -3
View File
@@ -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
+22
View File
@@ -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
+1
View File
@@ -43,3 +43,4 @@ scripts/generate-api-sdk/swagger.json
docker-compose.override.yml
ErsatzTV/wwwroot/v2/
+31 -1
View File
@@ -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
+1 -1
View File
@@ -17,7 +17,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asmichi.ChildProcess" Version="0.11.0" />
<PackageReference Include="CliWrap" Version="3.4.2" />
</ItemGroup>
<ItemGroup>
+10 -6
View File
@@ -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);
}
+20 -4
View File
@@ -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);
}
}
}
@@ -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,
+41 -84
View File
@@ -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 -10
View File
@@ -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(
_ => { },
+2 -2
View File
@@ -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);
+20 -11
View File
@@ -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;
}
}
+27 -160
View File
@@ -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)
{
+4 -2
View File
@@ -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())
{
+1
View File
@@ -7,5 +7,6 @@ public enum TempFileCategory
CoverArt = 2,
CachedArtwork = 3,
BadTranscodeFolder = 98,
BadPlaylist = 99
}
+2 -1
View File
@@ -8,5 +8,6 @@ public enum VaapiDriver
Default = 0,
iHD = 1,
i965 = 2,
RadeonSI = 3
RadeonSI = 3,
Nouveau = 4
}
+1 -1
View File
@@ -2,5 +2,5 @@
public interface IHealthCheck
{
Task<HealthCheckResult> Check();
Task<HealthCheckResult> Check(CancellationToken cancellationToken);
}
+1 -1
View File
@@ -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);
}
+2 -2
View File
@@ -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(
_ => { },
+15 -6
View File
@@ -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"))
{
+9 -6
View File
@@ -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);
+19 -10
View File
@@ -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,
+3 -2
View File
@@ -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>
+5
View File
@@ -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());
}
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