Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e5ff198b | ||
|
|
6be5111195 | ||
|
|
f0670b345f | ||
|
|
6a1c2b7659 | ||
|
|
7cd2f9a56f | ||
|
|
f66bc783a7 | ||
|
|
bc225d35fa | ||
|
|
52a8b7db81 | ||
|
|
dcd792a354 |
+11
-1
@@ -5,6 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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 +1017,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.3-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.4-alpha...HEAD
|
||||
[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
|
||||
|
||||
@@ -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.1" />
|
||||
<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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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.1" />
|
||||
<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" />
|
||||
|
||||
@@ -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;
|
||||
@@ -187,8 +188,10 @@ public class TranscodingTests
|
||||
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.NvidiaCodecs))]
|
||||
string profileCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))]
|
||||
HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.QsvCodecs))] string profileCodec,
|
||||
@@ -213,9 +216,11 @@ public class TranscodingTests
|
||||
{
|
||||
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 +240,7 @@ public class TranscodingTests
|
||||
}
|
||||
|
||||
var imageCache = new Mock<IImageCache>();
|
||||
|
||||
|
||||
// always return the static watermark resource
|
||||
imageCache.Setup(
|
||||
ic => ic.GetPathForImage(
|
||||
@@ -269,11 +274,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 +288,7 @@ public class TranscodingTests
|
||||
LoggerFactory.CreateLogger<LocalStatisticsProvider>());
|
||||
|
||||
await localStatisticsProvider.RefreshStatistics(
|
||||
ExecutableName("ffmpeg"),
|
||||
ExecutableName("ffprobe"),
|
||||
new Movie
|
||||
{
|
||||
@@ -344,7 +351,7 @@ public class TranscodingTests
|
||||
break;
|
||||
}
|
||||
|
||||
Process process = await service.ForPlayoutItem(
|
||||
using Process process = await service.ForPlayoutItem(
|
||||
ExecutableName("ffmpeg"),
|
||||
false,
|
||||
new Channel(Guid.NewGuid())
|
||||
@@ -375,13 +382,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 +391,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 +426,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();
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<EmbyItemEtag> existingMovies = await _movieRepository.GetExistingEmbyMovies(library);
|
||||
@@ -172,7 +173,11 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
incomingMovie,
|
||||
localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -51,6 +51,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<EmbyItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
|
||||
@@ -70,7 +71,15 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
await maybeShows.Match(
|
||||
async shows =>
|
||||
{
|
||||
await ProcessShows(address, apiKey, library, ffprobePath, pathReplacements, existingShows, shows);
|
||||
await ProcessShows(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
existingShows,
|
||||
shows);
|
||||
|
||||
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
|
||||
var showIds = existingShows
|
||||
@@ -104,6 +113,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<EmbyPathReplacement> pathReplacements,
|
||||
List<EmbyItemEtag> existingShows,
|
||||
@@ -167,6 +177,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
@@ -196,6 +207,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<EmbyPathReplacement> pathReplacements,
|
||||
EmbyShow show,
|
||||
@@ -286,6 +298,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
@@ -318,6 +331,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
string showName,
|
||||
string seasonName,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<EmbyPathReplacement> pathReplacements,
|
||||
EmbySeason season,
|
||||
@@ -406,7 +420,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, incomingEpisode, localPath);
|
||||
|
||||
refreshResult.Match(
|
||||
_ => { },
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -301,7 +301,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
WatermarkLocation watermarkLocation,
|
||||
int horizontalMarginPercent,
|
||||
int verticalMarginPercent,
|
||||
int watermarkWidthPercent) =>
|
||||
int watermarkWidthPercent,
|
||||
CancellationToken cancellationToken) =>
|
||||
_ffmpegProcessService.GenerateSongImage(
|
||||
ffmpegPath,
|
||||
subtitleFile,
|
||||
@@ -314,7 +315,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
watermarkLocation,
|
||||
horizontalMarginPercent,
|
||||
verticalMarginPercent,
|
||||
watermarkWidthPercent);
|
||||
watermarkWidthPercent,
|
||||
cancellationToken);
|
||||
|
||||
private Process GetProcess(
|
||||
string ffmpegPath,
|
||||
@@ -389,6 +391,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
return "iHD";
|
||||
case VaapiDriver.RadeonSI:
|
||||
return "radeonsi";
|
||||
case VaapiDriver.Nouveau:
|
||||
return "nouveau";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -682,6 +682,9 @@ internal class FFmpegProcessBuilder
|
||||
case VaapiDriver.RadeonSI:
|
||||
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "radeonsi";
|
||||
break;
|
||||
case VaapiDriver.Nouveau:
|
||||
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "nouveau";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -331,7 +332,8 @@ public class FFmpegProcessService : IFFmpegProcessService
|
||||
WatermarkLocation watermarkLocation,
|
||||
int horizontalMarginPercent,
|
||||
int verticalMarginPercent,
|
||||
int watermarkWidthPercent)
|
||||
int watermarkWidthPercent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -404,8 +406,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;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ public class SongVideoGenerator : ISongVideoGenerator
|
||||
Song song,
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> maybeGlobalWatermark,
|
||||
string ffmpegPath)
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> subtitleFile = None;
|
||||
|
||||
@@ -224,7 +225,8 @@ public class SongVideoGenerator : ISongVideoGenerator
|
||||
watermarkLocation,
|
||||
HORIZONTAL_MARGIN_PERCENT,
|
||||
VERTICAL_MARGIN_PERCENT,
|
||||
WATERMARK_WIDTH_PERCENT);
|
||||
WATERMARK_WIDTH_PERCENT,
|
||||
cancellationToken);
|
||||
|
||||
foreach (string si in maybeSongImage.RightToSeq())
|
||||
{
|
||||
|
||||
@@ -7,5 +7,6 @@ public enum TempFileCategory
|
||||
CoverArt = 2,
|
||||
CachedArtwork = 3,
|
||||
|
||||
BadTranscodeFolder = 98,
|
||||
BadPlaylist = 99
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public enum VaapiDriver
|
||||
Default = 0,
|
||||
iHD = 1,
|
||||
i965 = 2,
|
||||
RadeonSI = 3
|
||||
RadeonSI = 3,
|
||||
Nouveau = 4
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
public interface IHealthCheck
|
||||
{
|
||||
Task<HealthCheckResult> Check();
|
||||
Task<HealthCheckResult> Check(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
public interface IHealthCheckService
|
||||
{
|
||||
Task<List<HealthCheckResult>> PerformHealthChecks();
|
||||
Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface IEmbyMovieLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface IEmbyTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -57,5 +57,6 @@ public interface IFFmpegProcessService
|
||||
WatermarkLocation watermarkLocation,
|
||||
int horizontalMarginPercent,
|
||||
int verticalMarginPercent,
|
||||
int watermarkWidthPercent);
|
||||
int watermarkWidthPercent,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface ISongVideoGenerator
|
||||
Song song,
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> maybeGlobalWatermark,
|
||||
string ffmpegPath);
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface IJellyfinMovieLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public interface IJellyfinTelevisionLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -4,8 +4,13 @@ namespace ErsatzTV.Core.Interfaces.Metadata;
|
||||
|
||||
public interface ILocalStatisticsProvider
|
||||
{
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem, string mediaItemPath);
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
|
||||
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
MediaItem mediaItem,
|
||||
string mediaItemPath);
|
||||
|
||||
Task<Either<BaseError, Dictionary<string, string>>> GetFormatTags(string ffprobePath, MediaItem mediaItem);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ public interface IMovieFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ public interface IMusicVideoFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ public interface IOtherVideoFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
|
||||
@@ -9,5 +9,6 @@ public interface ISongFolderScanner
|
||||
string ffprobePath,
|
||||
string ffmpegPath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ public interface ITelevisionFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -9,5 +9,6 @@ public interface IPlexMovieLibraryScanner
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -9,5 +9,6 @@ public interface IPlexTelevisionLibraryScanner
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<JellyfinItemEtag> existingMovies = await _movieRepository.GetExistingJellyfinMovies(library);
|
||||
@@ -172,7 +173,11 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
incomingMovie,
|
||||
localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -51,6 +51,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
|
||||
@@ -70,7 +71,15 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
await maybeShows.Match(
|
||||
async shows =>
|
||||
{
|
||||
await ProcessShows(address, apiKey, library, ffprobePath, pathReplacements, existingShows, shows);
|
||||
await ProcessShows(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
existingShows,
|
||||
shows);
|
||||
|
||||
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
|
||||
var showIds = existingShows
|
||||
@@ -104,6 +113,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
List<JellyfinItemEtag> existingShows,
|
||||
@@ -167,6 +177,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
@@ -196,6 +207,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
JellyfinShow show,
|
||||
@@ -286,6 +298,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
@@ -319,6 +332,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
string showName,
|
||||
string seasonName,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
JellyfinSeason season,
|
||||
@@ -408,7 +422,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, incomingEpisode, localPath);
|
||||
|
||||
refreshResult.Match(
|
||||
_ => { },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -79,6 +80,7 @@ public abstract class LocalFolderScanner
|
||||
|
||||
protected async Task<Either<BaseError, MediaItemScanResult<T>>> UpdateStatistics<T>(
|
||||
MediaItemScanResult<T> mediaItem,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
where T : MediaItem
|
||||
{
|
||||
@@ -92,7 +94,7 @@ public abstract class LocalFolderScanner
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem.Item);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, mediaItem.Item);
|
||||
refreshResult.Match(
|
||||
result =>
|
||||
{
|
||||
@@ -123,7 +125,8 @@ public abstract class LocalFolderScanner
|
||||
Domain.Metadata metadata,
|
||||
ArtworkKind artworkKind,
|
||||
Option<string> ffmpegPath,
|
||||
Option<int> attachedPicIndex)
|
||||
Option<int> attachedPicIndex,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
|
||||
|
||||
@@ -167,8 +170,11 @@ public abstract class LocalFolderScanner
|
||||
artworkFile,
|
||||
picIndex,
|
||||
tempName);
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
await Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(cancellationToken);
|
||||
|
||||
return tempName;
|
||||
},
|
||||
@@ -177,8 +183,11 @@ public abstract class LocalFolderScanner
|
||||
// no attached pic index means convert to png
|
||||
string tempName = _tempFilePool.GetNextTempFile(TempFileCategory.CoverArt);
|
||||
using Process process = ffmpegProcessService.ConvertToPng(path, artworkFile, tempName);
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
await Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(cancellationToken);
|
||||
|
||||
return tempName;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
@@ -30,12 +31,12 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
|
||||
public async Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
|
||||
return await RefreshStatistics(ffprobePath, mediaItem, filePath);
|
||||
return await RefreshStatistics(ffmpegPath, ffprobePath, mediaItem, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -46,6 +47,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, bool>> RefreshStatistics(
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
MediaItem mediaItem,
|
||||
string mediaItemPath)
|
||||
@@ -57,6 +59,11 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
async ffprobe =>
|
||||
{
|
||||
MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe);
|
||||
if (version.Duration.TotalSeconds < 1)
|
||||
{
|
||||
await AnalyzeDuration(ffmpegPath, mediaItemPath, version);
|
||||
}
|
||||
|
||||
bool result = await ApplyVersionUpdate(mediaItem, version, mediaItemPath);
|
||||
return Right<BaseError, bool>(result);
|
||||
},
|
||||
@@ -185,6 +192,67 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
});
|
||||
}
|
||||
|
||||
private async Task AnalyzeDuration(string ffmpegPath, string path, MediaVersion version)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Media item at {Path} is missing duration metadata and requires additional analysis",
|
||||
path);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(path);
|
||||
startInfo.ArgumentList.Add("-f");
|
||||
startInfo.ArgumentList.Add("null");
|
||||
startInfo.ArgumentList.Add("-");
|
||||
|
||||
var probe = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
probe.Start();
|
||||
string output = await probe.StandardError.ReadToEndAsync();
|
||||
await probe.WaitForExitAsync();
|
||||
if (probe.ExitCode == 0)
|
||||
{
|
||||
const string PATTERN = @"time=([^ ]+)";
|
||||
IEnumerable<string> reversed = output.Split("\n").Reverse();
|
||||
foreach (string line in reversed)
|
||||
{
|
||||
Match match = Regex.Match(line, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
string time = match.Groups[1].Value;
|
||||
var duration = TimeSpan.Parse(time, NumberFormatInfo.InvariantInfo);
|
||||
_logger.LogInformation("Analyzed duration is {Duration}", duration);
|
||||
version.Duration = duration;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Duration analysis failed for media item at {Path}", path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
_logger.LogError("Duration analysis failed for media item at {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
internal MediaVersion ProjectToMediaVersion(string path, FFprobe probeOutput) =>
|
||||
Optional(probeOutput)
|
||||
.Filter(json => json?.format != null && json.streams != null)
|
||||
@@ -210,13 +278,13 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
var seconds = TimeSpan.FromSeconds(duration);
|
||||
version.Duration = seconds;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Media item at {Path} has a missing or invalid duration {Duration} and will cause scheduling issues",
|
||||
path,
|
||||
json.format.duration);
|
||||
}
|
||||
// else
|
||||
// {
|
||||
// _logger.LogWarning(
|
||||
// "Media item at {Path} has a missing or invalid duration {Duration} and will cause scheduling issues",
|
||||
// path,
|
||||
// json.format.duration);
|
||||
// }
|
||||
|
||||
foreach (FFprobeStream audioStream in json.streams.Filter(s => s.codec_type == "audio"))
|
||||
{
|
||||
|
||||
@@ -64,9 +64,11 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
@@ -131,10 +133,10 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath))
|
||||
.BindT(movie => UpdateStatistics(movie, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken))
|
||||
.BindT(FlagNormal);
|
||||
|
||||
await maybeMovie.Match(
|
||||
@@ -228,7 +230,8 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateArtwork(
|
||||
MediaItemScanResult<Movie> result,
|
||||
ArtworkKind artworkKind)
|
||||
ArtworkKind artworkKind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -237,7 +240,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind, None, None);
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -65,9 +65,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
@@ -87,17 +89,19 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
|
||||
await FindOrCreateArtist(libraryPath.Id, artistFolder)
|
||||
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
|
||||
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail))
|
||||
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt));
|
||||
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail, cancellationToken))
|
||||
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken));
|
||||
|
||||
await maybeArtist.Match(
|
||||
async result =>
|
||||
{
|
||||
await ScanMusicVideos(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
result.Item,
|
||||
artistFolder);
|
||||
artistFolder,
|
||||
cancellationToken);
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -210,7 +214,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> UpdateArtworkForArtist(
|
||||
MediaItemScanResult<Artist> result,
|
||||
string artistFolder,
|
||||
ArtworkKind artworkKind)
|
||||
ArtworkKind artworkKind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -219,7 +224,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
async artworkFile =>
|
||||
{
|
||||
ArtistMetadata metadata = artist.ArtistMetadata.Head();
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -233,9 +238,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
|
||||
private async Task ScanMusicVideos(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
Artist artist,
|
||||
string artistFolder)
|
||||
string artistFolder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var folderQueue = new Queue<string>();
|
||||
folderQueue.Enqueue(artistFolder);
|
||||
@@ -272,9 +279,9 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
// TODO: figure out how to rebuild playouts
|
||||
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo = await _musicVideoRepository
|
||||
.GetOrAdd(artist, libraryPath, file)
|
||||
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
|
||||
.BindT(musicVideo => UpdateStatistics(musicVideo, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail)
|
||||
.BindT(result => UpdateThumbnail(result, cancellationToken))
|
||||
.BindT(FlagNormal);
|
||||
|
||||
await maybeMusicVideo.Match(
|
||||
@@ -376,7 +383,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateThumbnail(
|
||||
MediaItemScanResult<MusicVideo> result)
|
||||
MediaItemScanResult<MusicVideo> result,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -385,7 +393,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
async thumbnailFile =>
|
||||
{
|
||||
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None);
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -62,6 +62,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
@@ -126,7 +127,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
{
|
||||
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffprobePath))
|
||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(FlagNormal);
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
string ffprobePath,
|
||||
string ffmpegPath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
@@ -128,9 +129,9 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
{
|
||||
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffprobePath))
|
||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||
.BindT(video => UpdateMetadata(video, ffprobePath))
|
||||
.BindT(video => UpdateThumbnail(video, ffmpegPath))
|
||||
.BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken))
|
||||
.BindT(FlagNormal);
|
||||
|
||||
await maybeSong.Match(
|
||||
@@ -212,7 +213,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail(
|
||||
MediaItemScanResult<Song> result,
|
||||
string ffmpegPath)
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -234,10 +236,16 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
async thumbnailFile =>
|
||||
{
|
||||
SongMetadata metadata = song.SongMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, ffmpegPath, None);
|
||||
await RefreshArtwork(
|
||||
thumbnailFile,
|
||||
metadata,
|
||||
ArtworkKind.Thumbnail,
|
||||
ffmpegPath,
|
||||
None,
|
||||
cancellationToken);
|
||||
},
|
||||
() => ExtractEmbeddedArtwork(song, ffmpegPath));
|
||||
|
||||
() => ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken));
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -246,7 +254,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private Option<string> LocateThumbnail(Song song)
|
||||
{
|
||||
string path = song.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
@@ -263,7 +271,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
}).Flatten();
|
||||
}
|
||||
|
||||
private async Task ExtractEmbeddedArtwork(Song song, string ffmpegPath)
|
||||
private async Task ExtractEmbeddedArtwork(Song song, string ffmpegPath, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<MediaStream> maybeArtworkStream = Optional(song.GetHeadVersion().Streams.Find(ms => ms.AttachedPic));
|
||||
foreach (MediaStream artworkStream in maybeArtworkStream)
|
||||
@@ -273,7 +281,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
song.SongMetadata.Head(),
|
||||
ArtworkKind.Thumbnail,
|
||||
ffmpegPath,
|
||||
artworkStream.Index);
|
||||
artworkStream.Index,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
@@ -84,18 +86,20 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail));
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster, cancellationToken))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt, cancellationToken))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken));
|
||||
|
||||
await maybeShow.Match(
|
||||
async result =>
|
||||
{
|
||||
await ScanSeasons(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
result.Item,
|
||||
showFolder);
|
||||
showFolder,
|
||||
cancellationToken);
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -154,9 +158,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
private async Task<Unit> ScanSeasons(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
Show show,
|
||||
string showFolder)
|
||||
string showFolder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
@@ -179,12 +185,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
Either<BaseError, Season> maybeSeason = await _televisionRepository
|
||||
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
|
||||
.BindT(EnsureMetadataExists)
|
||||
.BindT(season => UpdatePoster(season, seasonFolder));
|
||||
.BindT(season => UpdatePoster(season, seasonFolder, cancellationToken));
|
||||
|
||||
await maybeSeason.Match(
|
||||
async season =>
|
||||
{
|
||||
await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder);
|
||||
await ScanEpisodes(libraryPath, ffmpegPath, ffprobePath, season, seasonFolder, cancellationToken);
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
|
||||
|
||||
season.Show = show;
|
||||
@@ -206,9 +212,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
private async Task<Unit> ScanEpisodes(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
Season season,
|
||||
string seasonPath)
|
||||
string seasonPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allSeasonFiles = _localFileSystem.ListSubdirectories(seasonPath)
|
||||
.Map(_localFileSystem.ListFiles)
|
||||
@@ -225,10 +233,10 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
|
||||
.GetOrAddEpisode(season, libraryPath, file)
|
||||
.BindT(
|
||||
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
|
||||
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffmpegPath, ffprobePath)
|
||||
.MapT(_ => episode))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail)
|
||||
.BindT(e => UpdateThumbnail(e, cancellationToken))
|
||||
.BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e)))
|
||||
.MapT(r => r.Item);
|
||||
|
||||
@@ -359,7 +367,8 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateArtworkForShow(
|
||||
MediaItemScanResult<Show> result,
|
||||
string showFolder,
|
||||
ArtworkKind artworkKind)
|
||||
ArtworkKind artworkKind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -368,7 +377,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
async artworkFile =>
|
||||
{
|
||||
ShowMetadata metadata = show.ShowMetadata.Head();
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -380,7 +389,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Season>> UpdatePoster(Season season, string seasonFolder)
|
||||
private async Task<Either<BaseError, Season>> UpdatePoster(Season season, string seasonFolder, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -388,7 +397,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
async posterFile =>
|
||||
{
|
||||
SeasonMetadata metadata = season.SeasonMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None, None);
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
return season;
|
||||
@@ -400,7 +409,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Episode>> UpdateThumbnail(Episode episode)
|
||||
private async Task<Either<BaseError, Episode>> UpdateThumbnail(Episode episode, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -409,7 +418,13 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
{
|
||||
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
|
||||
{
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail, None, None);
|
||||
await RefreshArtwork(
|
||||
posterFile,
|
||||
metadata,
|
||||
ArtworkKind.Thumbnail,
|
||||
None,
|
||||
None,
|
||||
cancellationToken);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
@@ -93,7 +94,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
.BindT(existing => UpdateStatistics(pathReplacements, existing, incoming, ffprobePath))
|
||||
.BindT(
|
||||
existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath))
|
||||
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
@@ -145,6 +147,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
@@ -178,7 +181,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -55,6 +55,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
@@ -82,7 +83,14 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
await maybeShow.Match(
|
||||
async result =>
|
||||
{
|
||||
await ScanSeasons(library, pathReplacements, result.Item, connection, token, ffprobePath);
|
||||
await ScanSeasons(
|
||||
library,
|
||||
pathReplacements,
|
||||
result.Item,
|
||||
connection,
|
||||
token,
|
||||
ffmpegPath,
|
||||
ffprobePath);
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -286,6 +294,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
|
||||
@@ -315,6 +324,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
season,
|
||||
connection,
|
||||
token,
|
||||
ffmpegPath,
|
||||
ffprobePath);
|
||||
|
||||
season.Show = show;
|
||||
@@ -384,6 +394,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
|
||||
@@ -431,6 +442,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
library,
|
||||
connection,
|
||||
token,
|
||||
ffmpegPath,
|
||||
ffprobePath))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
@@ -503,6 +515,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
@@ -535,7 +548,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.5.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="Moq" Version="4.17.1" />
|
||||
<PackageReference Include="Moq" Version="4.17.2" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
|
||||
@@ -93,6 +93,68 @@ public class PipelineGeneratorTests
|
||||
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -re -stream_loop -1 -i http://localhost:8080/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsDirect_Test()
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<VideoStream>
|
||||
{ new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) });
|
||||
|
||||
var audioInputFile = new AudioInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
|
||||
new AudioState(
|
||||
AudioFormat.Copy,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false));
|
||||
|
||||
var desiredState = new FrameState(
|
||||
true,
|
||||
false,
|
||||
VideoFormat.Copy,
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(1920, 1080),
|
||||
new FrameSize(1920, 1080),
|
||||
Option<int>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<TimeSpan>.None,
|
||||
Option<TimeSpan>.None,
|
||||
false,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
OutputFormatKind.MpegTs,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
0);
|
||||
|
||||
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, "", _logger);
|
||||
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
|
||||
|
||||
result.PipelineSteps.Should().HaveCountGreaterThan(0);
|
||||
result.PipelineSteps.Should().Contain(ps => ps is EncoderCopyVideo);
|
||||
result.PipelineSteps.Should().Contain(ps => ps is EncoderCopyAudio);
|
||||
|
||||
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
|
||||
|
||||
command.Should().Be(
|
||||
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
|
||||
}
|
||||
|
||||
private static string PrintCommand(
|
||||
Option<VideoInputFile> videoInputFile,
|
||||
Option<AudioInputFile> audioInputFile,
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -607,6 +607,11 @@ public class PipelineBuilder
|
||||
|
||||
private static bool IsDesiredVideoState(FrameState currentState, FrameState desiredState)
|
||||
{
|
||||
if (desiredState.VideoFormat == VideoFormat.Copy)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentState.VideoFormat == desiredState.VideoFormat &&
|
||||
currentState.PixelFormat.Match(pf => pf.Name, () => string.Empty) ==
|
||||
desiredState.PixelFormat.Match(pf => pf.Name, string.Empty) &&
|
||||
|
||||
@@ -9,16 +9,17 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blurhash.ImageSharp" Version="1.1.1" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.1" />
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.3">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.3" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using ErsatzTV.Core.Health;
|
||||
using Lucene.Net.Util;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Health.Checks;
|
||||
|
||||
@@ -28,27 +28,17 @@ public abstract class BaseHealthCheck
|
||||
|
||||
protected HealthCheckResult InfoResult(string message) =>
|
||||
new(Title, HealthCheckStatus.Info, message, None);
|
||||
|
||||
protected static async Task<string> GetProcessOutput(string path, IEnumerable<string> arguments)
|
||||
|
||||
protected static async Task<string> GetProcessOutput(
|
||||
string path,
|
||||
IEnumerable<string> arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
BufferedCommandResult result = await Cli.Wrap(path)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteBufferedAsync(cancellationToken);
|
||||
|
||||
startInfo.ArgumentList.AddRange(arguments);
|
||||
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
process.Start();
|
||||
string result = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
return result;
|
||||
return result.StandardOutput;
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,16 @@ public class EpisodeMetadataHealthCheck : BaseHealthCheck, IEpisodeMetadataHealt
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
protected override string Title => "Episode Metadata";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Episode> episodes = await dbContext.Episodes
|
||||
.Filter(e => e.EpisodeMetadata.Count == 0)
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (episodes.Any())
|
||||
{
|
||||
@@ -36,7 +36,8 @@ public class EpisodeMetadataHealthCheck : BaseHealthCheck, IEpisodeMetadataHealt
|
||||
|
||||
var folders = string.Join(", ", paths);
|
||||
|
||||
return WarningResult($"There are {episodes.Count} episodes with missing metadata, including in the following folders: {folders}");
|
||||
return WarningResult(
|
||||
$"There are {episodes.Count} episodes with missing metadata, including in the following folders: {folders}");
|
||||
}
|
||||
|
||||
return OkResult();
|
||||
|
||||
@@ -16,7 +16,7 @@ public class ErrorReportsHealthCheck : BaseHealthCheck, IErrorReportsHealthCheck
|
||||
|
||||
protected override string Title => "Error Reports";
|
||||
|
||||
public Task<HealthCheckResult> Check()
|
||||
public Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_bugsnagConfiguration.Value.Enable)
|
||||
{
|
||||
@@ -26,6 +26,7 @@ public class ErrorReportsHealthCheck : BaseHealthCheck, IErrorReportsHealthCheck
|
||||
.AsTask();
|
||||
}
|
||||
|
||||
return InfoResult("Automated error reporting is disabled. Please enable to support bug fixing efforts!").AsTask();
|
||||
return InfoResult("Automated error reporting is disabled. Please enable to support bug fixing efforts!")
|
||||
.AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class FFmpegReportsHealthCheck : BaseHealthCheck, IFFmpegReportsHealthChe
|
||||
public FFmpegReportsHealthCheck(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
Option<bool> saveReports =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
|
||||
|
||||
@@ -16,7 +16,7 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ConfigElement> maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
|
||||
if (maybeFFmpegPath.IsNone)
|
||||
@@ -29,9 +29,10 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
{
|
||||
return FailResult("Unable to locate ffprobe");
|
||||
}
|
||||
|
||||
foreach (ConfigElement ffmpegPath in maybeFFmpegPath)
|
||||
{
|
||||
Option<string> maybeVersion = await GetVersion(ffmpegPath.Value);
|
||||
Option<string> maybeVersion = await GetVersion(ffmpegPath.Value, cancellationToken);
|
||||
if (maybeVersion.IsNone)
|
||||
{
|
||||
return WarningResult("Unable to determine ffmpeg version");
|
||||
@@ -48,7 +49,7 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
|
||||
foreach (ConfigElement ffprobePath in maybeFFprobePath)
|
||||
{
|
||||
Option<string> maybeVersion = await GetVersion(ffprobePath.Value);
|
||||
Option<string> maybeVersion = await GetVersion(ffprobePath.Value, cancellationToken);
|
||||
if (maybeVersion.IsNone)
|
||||
{
|
||||
return WarningResult("Unable to determine ffprobe version");
|
||||
@@ -65,7 +66,7 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
|
||||
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty, None);
|
||||
}
|
||||
|
||||
|
||||
private Option<HealthCheckResult> ValidateVersion(string version, string app)
|
||||
{
|
||||
if (version.StartsWith("3.") || version.StartsWith("4."))
|
||||
@@ -82,9 +83,9 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
return None;
|
||||
}
|
||||
|
||||
private static async Task<Option<string>> GetVersion(string path)
|
||||
private static async Task<Option<string>> GetVersion(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeLine = await GetProcessOutput(path, new[] { "-version" })
|
||||
Option<string> maybeLine = await GetProcessOutput(path, new[] { "-version" }, cancellationToken)
|
||||
.Map(s => s.Split("\n").HeadOrNone().Map(h => h.Trim()));
|
||||
foreach (string line in maybeLine)
|
||||
{
|
||||
|
||||
@@ -16,58 +16,58 @@ public class FileNotFoundHealthCheck : BaseHealthCheck, IFileNotFoundHealthCheck
|
||||
|
||||
protected override string Title => "File Not Found";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Episode> episodes = await dbContext.Episodes
|
||||
.Filter(e => e.State == MediaItemState.FileNotFound)
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
List<Episode> episodes = await dbContext.Episodes
|
||||
.Filter(e => e.State == MediaItemState.FileNotFound)
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<Movie> movies = await dbContext.Movies
|
||||
.Filter(m => m.State == MediaItemState.FileNotFound)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
List<MusicVideo> musicVideos = await dbContext.MusicVideos
|
||||
.Filter(mv => mv.State == MediaItemState.FileNotFound)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
List<OtherVideo> otherVideos = await dbContext.OtherVideos
|
||||
.Filter(ov => ov.State == MediaItemState.FileNotFound)
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
List<Movie> movies = await dbContext.Movies
|
||||
.Filter(m => m.State == MediaItemState.FileNotFound)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<Song> songs = await dbContext.Songs
|
||||
.Filter(s => s.State == MediaItemState.FileNotFound)
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
var all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
|
||||
.Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path))
|
||||
.Append(musicVideos.Map(mv => mv.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.Append(otherVideos.Map(ov => ov.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.Append(songs.Map(s => s.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.ToList();
|
||||
List<MusicVideo> musicVideos = await dbContext.MusicVideos
|
||||
.Filter(mv => mv.State == MediaItemState.FileNotFound)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (all.Any())
|
||||
{
|
||||
var paths = all.Take(5).ToList();
|
||||
List<OtherVideo> otherVideos = await dbContext.OtherVideos
|
||||
.Filter(ov => ov.State == MediaItemState.FileNotFound)
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var files = string.Join(", ", paths);
|
||||
List<Song> songs = await dbContext.Songs
|
||||
.Filter(s => s.State == MediaItemState.FileNotFound)
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return WarningResult(
|
||||
$"There are {all.Count} files that do not exist on disk, including the following: {files}",
|
||||
"/media/trash");
|
||||
}
|
||||
var all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
|
||||
.Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path))
|
||||
.Append(musicVideos.Map(mv => mv.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.Append(otherVideos.Map(ov => ov.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.Append(songs.Map(s => s.GetHeadVersion().MediaFiles.Head().Path))
|
||||
.ToList();
|
||||
|
||||
return OkResult();
|
||||
if (all.Any())
|
||||
{
|
||||
var paths = all.Take(5).ToList();
|
||||
|
||||
var files = string.Join(", ", paths);
|
||||
|
||||
return WarningResult(
|
||||
$"There are {all.Count} files that do not exist on disk, including the following: {files}",
|
||||
"/media/trash");
|
||||
}
|
||||
|
||||
return OkResult();
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ConfigElement> maybeFFmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
|
||||
if (maybeFFmpegPath.IsNone)
|
||||
@@ -49,7 +49,8 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
|
||||
|
||||
if (!accelerationKinds.Any())
|
||||
{
|
||||
accelerationKinds.AddRange(await GetSupportedAccelerationKinds(maybeFFmpegPath.ValueUnsafe().Value));
|
||||
accelerationKinds.AddRange(
|
||||
await GetSupportedAccelerationKinds(maybeFFmpegPath.ValueUnsafe().Value, cancellationToken));
|
||||
}
|
||||
|
||||
if (!accelerationKinds.Any())
|
||||
@@ -69,7 +70,7 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
|
||||
private async Task<Option<HealthCheckResult>> VerifyProfilesUseAcceleration(
|
||||
IEnumerable<HardwareAccelerationKind> accelerationKinds)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<Channel> badChannels = await dbContext.Channels
|
||||
.Filter(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
@@ -87,11 +88,13 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
|
||||
return None;
|
||||
}
|
||||
|
||||
private static async Task<List<HardwareAccelerationKind>> GetSupportedAccelerationKinds(string ffmpegPath)
|
||||
private static async Task<List<HardwareAccelerationKind>> GetSupportedAccelerationKinds(
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new System.Collections.Generic.HashSet<HardwareAccelerationKind>();
|
||||
|
||||
string output = await GetProcessOutput(ffmpegPath, new[] { "-v", "quiet", "-hwaccels" });
|
||||
|
||||
string output = await GetProcessOutput(ffmpegPath, new[] { "-v", "quiet", "-hwaccels" }, cancellationToken);
|
||||
foreach (string method in output.Split("\n").Map(s => s.Trim()).Skip(1))
|
||||
{
|
||||
switch (method)
|
||||
|
||||
@@ -14,16 +14,16 @@ public class MovieMetadataHealthCheck : BaseHealthCheck, IMovieMetadataHealthChe
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
protected override string Title => "Movie Metadata";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Movie> movies = await dbContext.Movies
|
||||
.Filter(e => e.MovieMetadata.Count == 0)
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (movies.Any())
|
||||
{
|
||||
@@ -36,7 +36,8 @@ public class MovieMetadataHealthCheck : BaseHealthCheck, IMovieMetadataHealthChe
|
||||
|
||||
var folders = string.Join(", ", paths);
|
||||
|
||||
return WarningResult($"There are {movies.Count} movies with missing metadata, including in the following folders: {folders}");
|
||||
return WarningResult(
|
||||
$"There are {movies.Count} movies with missing metadata, including in the following folders: {folders}");
|
||||
}
|
||||
|
||||
return OkResult();
|
||||
|
||||
@@ -18,13 +18,13 @@ public class VaapiDriverHealthCheck : BaseHealthCheck, IVaapiDriverHealthCheck
|
||||
|
||||
protected override string Title => "VAAPI Driver";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FFmpegProfile> profiles = await dbContext.FFmpegProfiles
|
||||
.Filter(p => p.HardwareAcceleration == HardwareAccelerationKind.Vaapi)
|
||||
.ToListAsync();
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (profiles.Count == 0)
|
||||
{
|
||||
return NotApplicableResult();
|
||||
|
||||
@@ -16,40 +16,40 @@ public class ZeroDurationHealthCheck : BaseHealthCheck, IZeroDurationHealthCheck
|
||||
|
||||
protected override string Title => "Zero Duration";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Episode> episodes = await dbContext.Episodes
|
||||
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<Movie> movies = await dbContext.Movies
|
||||
.Filter(m => m.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<MusicVideo> musicVideos = await dbContext.MusicVideos
|
||||
.Filter(mv => mv.MediaVersions.Any(v => v.Duration == TimeSpan.Zero))
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<OtherVideo> otherVideos = await dbContext.OtherVideos
|
||||
.Filter(ov => ov.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<Song> songs = await dbContext.Songs
|
||||
.Filter(s => s.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
|
||||
.Include(s => s.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.ToListAsync();
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
|
||||
.Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path))
|
||||
.Append(musicVideos.Map(mv => mv.GetHeadVersion().MediaFiles.Head().Path))
|
||||
|
||||
@@ -33,6 +33,6 @@ public class HealthCheckService : IHealthCheckService
|
||||
};
|
||||
}
|
||||
|
||||
public Task<List<HealthCheckResult>> PerformHealthChecks() =>
|
||||
_checks.Map(c => c.Check()).SequenceParallel().Map(results => results.ToList());
|
||||
public Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken) =>
|
||||
_checks.Map(c => c.Check(cancellationToken)).SequenceParallel().Map(results => results.ToList());
|
||||
}
|
||||
+3886
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Analyze_ZeroDurationFiles : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
|
||||
(
|
||||
SELECT LF.Id FROM LibraryFolder LF
|
||||
INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id
|
||||
INNER JOIN Library L on LP.LibraryId = L.Id
|
||||
INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
INNER JOIN MediaVersion MV on MI.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
|
||||
WHERE MV.Duration = '00:00:00.0000000'
|
||||
)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
|
||||
(
|
||||
SELECT LP.Id FROM LibraryPath LP
|
||||
INNER JOIN Library L on LP.LibraryId = L.Id
|
||||
INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
INNER JOIN MediaVersion MV on MI.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
|
||||
WHERE MV.Duration = '00:00:00.0000000'
|
||||
)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
|
||||
(
|
||||
SELECT L.Id FROM Library L
|
||||
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
|
||||
INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
INNER JOIN MediaVersion MV on MI.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
|
||||
WHERE MV.Duration = '00:00:00.0000000'
|
||||
)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00' WHERE Duration = '00:00:00.0000000'");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,12 @@
|
||||
<PackageReference Include="FluentValidation" Version="10.3.6" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.6" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="7.1.488" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="Markdig" Version="0.27.0" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" 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>
|
||||
|
||||
@@ -26,61 +26,68 @@ public class EmbyService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(FileSystemLayout.EmbySecretsPath))
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(FileSystemLayout.EmbySecretsPath, "{}", cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emby service started; secrets are at {EmbySecretsPath}",
|
||||
FileSystemLayout.EmbySecretsPath);
|
||||
|
||||
// synchronize sources on startup
|
||||
await SynchronizeSources(new SynchronizeEmbyMediaSources(), cancellationToken);
|
||||
|
||||
await foreach (IEmbyBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
try
|
||||
if (!File.Exists(FileSystemLayout.EmbySecretsPath))
|
||||
{
|
||||
Task requestTask;
|
||||
switch (request)
|
||||
{
|
||||
case SynchronizeEmbyMediaSources synchronizeEmbyMediaSources:
|
||||
requestTask = SynchronizeSources(synchronizeEmbyMediaSources, cancellationToken);
|
||||
break;
|
||||
// case SynchronizeEmbyAdminUserId synchronizeEmbyAdminUserId:
|
||||
// requestTask = SynchronizeAdminUserId(synchronizeEmbyAdminUserId, cancellationToken);
|
||||
// break;
|
||||
case SynchronizeEmbyLibraries synchronizeEmbyLibraries:
|
||||
requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, cancellationToken);
|
||||
break;
|
||||
case ISynchronizeEmbyLibraryById synchronizeEmbyLibraryById:
|
||||
requestTask = SynchronizeEmbyLibrary(synchronizeEmbyLibraryById, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
|
||||
}
|
||||
|
||||
await requestTask;
|
||||
await File.WriteAllTextAsync(FileSystemLayout.EmbySecretsPath, "{}", cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process Emby background service request");
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emby service started; secrets are at {EmbySecretsPath}",
|
||||
FileSystemLayout.EmbySecretsPath);
|
||||
|
||||
// synchronize sources on startup
|
||||
await SynchronizeSources(new SynchronizeEmbyMediaSources(), cancellationToken);
|
||||
|
||||
await foreach (IEmbyBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
using (IServiceScope scope = _serviceScopeFactory.CreateScope())
|
||||
Task requestTask;
|
||||
switch (request)
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
case SynchronizeEmbyMediaSources synchronizeEmbyMediaSources:
|
||||
requestTask = SynchronizeSources(synchronizeEmbyMediaSources, cancellationToken);
|
||||
break;
|
||||
// case SynchronizeEmbyAdminUserId synchronizeEmbyAdminUserId:
|
||||
// requestTask = SynchronizeAdminUserId(synchronizeEmbyAdminUserId, cancellationToken);
|
||||
// break;
|
||||
case SynchronizeEmbyLibraries synchronizeEmbyLibraries:
|
||||
requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, cancellationToken);
|
||||
break;
|
||||
case ISynchronizeEmbyLibraryById synchronizeEmbyLibraryById:
|
||||
requestTask = SynchronizeEmbyLibrary(synchronizeEmbyLibraryById, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
|
||||
}
|
||||
|
||||
await requestTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process Emby background service request");
|
||||
|
||||
try
|
||||
{
|
||||
using (IServiceScope scope = _serviceScopeFactory.CreateScope())
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Emby service shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SynchronizeSources(
|
||||
|
||||
@@ -27,40 +27,47 @@ public class FFmpegWorkerService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("FFmpeg worker service started");
|
||||
|
||||
await foreach (IFFmpegWorkerRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
try
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
_logger.LogInformation("FFmpeg worker service started");
|
||||
|
||||
try
|
||||
await foreach (IFFmpegWorkerRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
|
||||
switch (request)
|
||||
{
|
||||
case TouchFFmpegSession touchFFmpegSession:
|
||||
foreach (DirectoryInfo parent in Optional(Directory.GetParent(touchFFmpegSession.Path)))
|
||||
{
|
||||
_ffmpegSegmenterService.TouchChannel(parent.Name);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to handle ffmpeg worker request");
|
||||
|
||||
try
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
|
||||
switch (request)
|
||||
{
|
||||
case TouchFFmpegSession touchFFmpegSession:
|
||||
foreach (DirectoryInfo parent in Optional(Directory.GetParent(touchFFmpegSession.Path)))
|
||||
{
|
||||
_ffmpegSegmenterService.TouchChannel(parent.Name);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// do nothing
|
||||
_logger.LogWarning(ex, "Failed to handle ffmpeg worker request");
|
||||
|
||||
try
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("FFmpeg worker service shutting down");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,61 +26,68 @@ public class JellyfinService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(FileSystemLayout.JellyfinSecretsPath))
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(FileSystemLayout.JellyfinSecretsPath, "{}", cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Jellyfin service started; secrets are at {JellyfinSecretsPath}",
|
||||
FileSystemLayout.JellyfinSecretsPath);
|
||||
|
||||
// synchronize sources on startup
|
||||
await SynchronizeSources(new SynchronizeJellyfinMediaSources(), cancellationToken);
|
||||
|
||||
await foreach (IJellyfinBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
try
|
||||
if (!File.Exists(FileSystemLayout.JellyfinSecretsPath))
|
||||
{
|
||||
Task requestTask;
|
||||
switch (request)
|
||||
{
|
||||
case SynchronizeJellyfinMediaSources synchronizeJellyfinMediaSources:
|
||||
requestTask = SynchronizeSources(synchronizeJellyfinMediaSources, cancellationToken);
|
||||
break;
|
||||
case SynchronizeJellyfinAdminUserId synchronizeJellyfinAdminUserId:
|
||||
requestTask = SynchronizeAdminUserId(synchronizeJellyfinAdminUserId, cancellationToken);
|
||||
break;
|
||||
case SynchronizeJellyfinLibraries synchronizeJellyfinLibraries:
|
||||
requestTask = SynchronizeLibraries(synchronizeJellyfinLibraries, cancellationToken);
|
||||
break;
|
||||
case ISynchronizeJellyfinLibraryById synchronizeJellyfinLibraryById:
|
||||
requestTask = SynchronizeJellyfinLibrary(synchronizeJellyfinLibraryById, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
|
||||
}
|
||||
|
||||
await requestTask;
|
||||
await File.WriteAllTextAsync(FileSystemLayout.JellyfinSecretsPath, "{}", cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
_logger.LogInformation(
|
||||
"Jellyfin service started; secrets are at {JellyfinSecretsPath}",
|
||||
FileSystemLayout.JellyfinSecretsPath);
|
||||
|
||||
// synchronize sources on startup
|
||||
await SynchronizeSources(new SynchronizeJellyfinMediaSources(), cancellationToken);
|
||||
|
||||
await foreach (IJellyfinBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process Jellyfin background service request");
|
||||
|
||||
try
|
||||
{
|
||||
using (IServiceScope scope = _serviceScopeFactory.CreateScope())
|
||||
Task requestTask;
|
||||
switch (request)
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
case SynchronizeJellyfinMediaSources synchronizeJellyfinMediaSources:
|
||||
requestTask = SynchronizeSources(synchronizeJellyfinMediaSources, cancellationToken);
|
||||
break;
|
||||
case SynchronizeJellyfinAdminUserId synchronizeJellyfinAdminUserId:
|
||||
requestTask = SynchronizeAdminUserId(synchronizeJellyfinAdminUserId, cancellationToken);
|
||||
break;
|
||||
case SynchronizeJellyfinLibraries synchronizeJellyfinLibraries:
|
||||
requestTask = SynchronizeLibraries(synchronizeJellyfinLibraries, cancellationToken);
|
||||
break;
|
||||
case ISynchronizeJellyfinLibraryById synchronizeJellyfinLibraryById:
|
||||
requestTask = SynchronizeJellyfinLibrary(synchronizeJellyfinLibraryById, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
|
||||
}
|
||||
|
||||
await requestTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process Jellyfin background service request");
|
||||
|
||||
try
|
||||
{
|
||||
using (IServiceScope scope = _serviceScopeFactory.CreateScope())
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Jellyfin service shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SynchronizeSources(
|
||||
|
||||
@@ -26,61 +26,68 @@ public class PlexService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(FileSystemLayout.PlexSecretsPath))
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(FileSystemLayout.PlexSecretsPath, "{}", cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Plex service started; secrets are at {PlexSecretsPath}",
|
||||
FileSystemLayout.PlexSecretsPath);
|
||||
|
||||
// synchronize sources on startup
|
||||
await SynchronizeSources(new SynchronizePlexMediaSources(), cancellationToken);
|
||||
|
||||
await foreach (IPlexBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
try
|
||||
if (!File.Exists(FileSystemLayout.PlexSecretsPath))
|
||||
{
|
||||
Task requestTask;
|
||||
switch (request)
|
||||
{
|
||||
case TryCompletePlexPinFlow pinRequest:
|
||||
requestTask = CompletePinFlow(pinRequest, cancellationToken);
|
||||
break;
|
||||
case SynchronizePlexMediaSources sourcesRequest:
|
||||
requestTask = SynchronizeSources(sourcesRequest, cancellationToken);
|
||||
break;
|
||||
case SynchronizePlexLibraries synchronizePlexLibrariesRequest:
|
||||
requestTask = SynchronizeLibraries(synchronizePlexLibrariesRequest, cancellationToken);
|
||||
break;
|
||||
case ISynchronizePlexLibraryById synchronizePlexLibraryById:
|
||||
requestTask = SynchronizePlexLibrary(synchronizePlexLibraryById, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
|
||||
}
|
||||
|
||||
await requestTask;
|
||||
await File.WriteAllTextAsync(FileSystemLayout.PlexSecretsPath, "{}", cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
_logger.LogInformation(
|
||||
"Plex service started; secrets are at {PlexSecretsPath}",
|
||||
FileSystemLayout.PlexSecretsPath);
|
||||
|
||||
// synchronize sources on startup
|
||||
await SynchronizeSources(new SynchronizePlexMediaSources(), cancellationToken);
|
||||
|
||||
await foreach (IPlexBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process plex background service request");
|
||||
|
||||
try
|
||||
{
|
||||
using (IServiceScope scope = _serviceScopeFactory.CreateScope())
|
||||
Task requestTask;
|
||||
switch (request)
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
case TryCompletePlexPinFlow pinRequest:
|
||||
requestTask = CompletePinFlow(pinRequest, cancellationToken);
|
||||
break;
|
||||
case SynchronizePlexMediaSources sourcesRequest:
|
||||
requestTask = SynchronizeSources(sourcesRequest, cancellationToken);
|
||||
break;
|
||||
case SynchronizePlexLibraries synchronizePlexLibrariesRequest:
|
||||
requestTask = SynchronizeLibraries(synchronizePlexLibrariesRequest, cancellationToken);
|
||||
break;
|
||||
case ISynchronizePlexLibraryById synchronizePlexLibraryById:
|
||||
requestTask = SynchronizePlexLibrary(synchronizePlexLibraryById, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
|
||||
}
|
||||
|
||||
await requestTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process plex background service request");
|
||||
|
||||
try
|
||||
{
|
||||
using (IServiceScope scope = _serviceScopeFactory.CreateScope())
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Plex service shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<PlexMediaSource>> SynchronizeSources(
|
||||
|
||||
@@ -51,8 +51,16 @@ public class SchedulerService : BackgroundService
|
||||
int currentMinutes = DateTime.Now.TimeOfDay.Minutes;
|
||||
int toWait = currentMinutes < 30 ? 30 - currentMinutes : 60 - currentMinutes;
|
||||
_logger.LogDebug("Scheduler sleeping for {Minutes} minutes", toWait);
|
||||
await Task.Delay(TimeSpan.FromMinutes(toWait), cancellationToken);
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(toWait), cancellationToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var roundedMinute = (int)(Math.Round(DateTime.Now.Minute / 5.0) * 5);
|
||||
|
||||
@@ -29,74 +29,90 @@ public class WorkerService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Worker service started");
|
||||
|
||||
await foreach (IBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
try
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
_logger.LogInformation("Worker service started");
|
||||
|
||||
try
|
||||
await foreach (IBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
switch (request)
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
case BuildPlayout buildPlayout:
|
||||
Either<BaseError, Unit> buildPlayoutResult = await mediator.Send(
|
||||
buildPlayout,
|
||||
cancellationToken);
|
||||
buildPlayoutResult.BiIter(
|
||||
_ => _logger.LogDebug("Built playout {PlayoutId}", buildPlayout.PlayoutId),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to build playout {PlayoutId}: {Error}",
|
||||
buildPlayout.PlayoutId,
|
||||
error.Value));
|
||||
break;
|
||||
case IScanLocalLibrary scanLocalLibrary:
|
||||
Either<BaseError, string> scanResult = await mediator.Send(
|
||||
scanLocalLibrary,
|
||||
cancellationToken);
|
||||
scanResult.BiIter(
|
||||
name => _logger.LogDebug(
|
||||
"Done scanning local library {Library}",
|
||||
name),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to scan local library {LibraryId}: {Error}",
|
||||
scanLocalLibrary.LibraryId,
|
||||
error.Value));
|
||||
break;
|
||||
case RebuildSearchIndex rebuildSearchIndex:
|
||||
await mediator.Send(rebuildSearchIndex, cancellationToken);
|
||||
break;
|
||||
case DeleteOrphanedArtwork deleteOrphanedArtwork:
|
||||
_logger.LogInformation("Deleting orphaned artwork from the database");
|
||||
await mediator.Send(deleteOrphanedArtwork, cancellationToken);
|
||||
break;
|
||||
case AddTraktList addTraktList:
|
||||
await mediator.Send(addTraktList, cancellationToken);
|
||||
break;
|
||||
case DeleteTraktList deleteTraktList:
|
||||
await mediator.Send(deleteTraktList, cancellationToken);
|
||||
break;
|
||||
case MatchTraktListItems matchTraktListItems:
|
||||
await mediator.Send(matchTraktListItems, cancellationToken);
|
||||
break;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process background service request");
|
||||
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
|
||||
try
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
switch (request)
|
||||
{
|
||||
case BuildPlayout buildPlayout:
|
||||
Either<BaseError, Unit> buildPlayoutResult = await mediator.Send(
|
||||
buildPlayout,
|
||||
cancellationToken);
|
||||
buildPlayoutResult.BiIter(
|
||||
_ => _logger.LogDebug("Built playout {PlayoutId}", buildPlayout.PlayoutId),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to build playout {PlayoutId}: {Error}",
|
||||
buildPlayout.PlayoutId,
|
||||
error.Value));
|
||||
break;
|
||||
case IScanLocalLibrary scanLocalLibrary:
|
||||
Either<BaseError, string> scanResult = await mediator.Send(
|
||||
scanLocalLibrary,
|
||||
cancellationToken);
|
||||
scanResult.BiIter(
|
||||
name => _logger.LogDebug(
|
||||
"Done scanning local library {Library}",
|
||||
name),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to scan local library {LibraryId}: {Error}",
|
||||
scanLocalLibrary.LibraryId,
|
||||
error.Value));
|
||||
break;
|
||||
case RebuildSearchIndex rebuildSearchIndex:
|
||||
await mediator.Send(rebuildSearchIndex, cancellationToken);
|
||||
break;
|
||||
case DeleteOrphanedArtwork deleteOrphanedArtwork:
|
||||
_logger.LogInformation("Deleting orphaned artwork from the database");
|
||||
await mediator.Send(deleteOrphanedArtwork, cancellationToken);
|
||||
break;
|
||||
case AddTraktList addTraktList:
|
||||
await mediator.Send(addTraktList, cancellationToken);
|
||||
break;
|
||||
case DeleteTraktList deleteTraktList:
|
||||
await mediator.Send(deleteTraktList, cancellationToken);
|
||||
break;
|
||||
case MatchTraktListItems matchTraktListItems:
|
||||
await mediator.Send(matchTraktListItems, cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// do nothing
|
||||
// this can happen when we're shutting down
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process background service request");
|
||||
|
||||
try
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Worker service shutting down");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user