Compare commits

...
Author SHA1 Message Date
Jason Dove f3e5ff198b update changelog for release v0.4.4-alpha [no ci] 2022-03-10 17:54:39 -06:00
Jason DoveandGitHub 6be5111195 fix errors; add nouveau vaapi driver option (#692)
* fix service shutdown errors

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

* analyze zero-duration files
2022-03-07 21:42:34 -06:00
Jason DoveandGitHub dcd792a354 fix hls direct streaming mode (#682) 2022-03-07 20:04:21 -06:00
79 changed files with 5098 additions and 664 deletions
+11 -1
View File
@@ -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);
}
}
}
@@ -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" />
+31 -39
View File
@@ -2,6 +2,7 @@
using System.Security.Cryptography;
using System.Text;
using Bugsnag;
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.FFmpeg;
@@ -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(
_ => { },
+2 -2
View File
@@ -9,11 +9,11 @@
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="Flurl" Version="3.0.4" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -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;
}
}
+7 -3
View File
@@ -1,5 +1,6 @@
using System.Diagnostics;
using Bugsnag;
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -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;
}
+4 -2
View File
@@ -30,7 +30,8 @@ public class SongVideoGenerator : ISongVideoGenerator
Song song,
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath)
string ffmpegPath,
CancellationToken cancellationToken)
{
Option<string> subtitleFile = None;
@@ -224,7 +225,8 @@ public class SongVideoGenerator : ISongVideoGenerator
watermarkLocation,
HORIZONTAL_MARGIN_PERCENT,
VERTICAL_MARGIN_PERCENT,
WATERMARK_WIDTH_PERCENT);
WATERMARK_WIDTH_PERCENT,
cancellationToken);
foreach (string si in maybeSongImage.RightToSeq())
{
+1
View File
@@ -7,5 +7,6 @@ public enum TempFileCategory
CoverArt = 2,
CachedArtwork = 3,
BadTranscodeFolder = 98,
BadPlaylist = 99
}
+2 -1
View File
@@ -8,5 +8,6 @@ public enum VaapiDriver
Default = 0,
iHD = 1,
i965 = 2,
RadeonSI = 3
RadeonSI = 3,
Nouveau = 4
}
+1 -1
View File
@@ -2,5 +2,5 @@
public interface IHealthCheck
{
Task<HealthCheckResult> Check();
Task<HealthCheckResult> Check(CancellationToken cancellationToken);
}
+1 -1
View File
@@ -2,5 +2,5 @@
public interface IHealthCheckService
{
Task<List<HealthCheckResult>> PerformHealthChecks();
Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken);
}
@@ -8,5 +8,6 @@ public interface IEmbyMovieLibraryScanner
string address,
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath);
}
@@ -8,5 +8,6 @@ public interface IEmbyTelevisionLibraryScanner
string address,
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath);
}
@@ -57,5 +57,6 @@ public interface IFFmpegProcessService
WatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent);
int watermarkWidthPercent,
CancellationToken cancellationToken);
}
@@ -8,5 +8,6 @@ public interface ISongVideoGenerator
Song song,
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath);
string ffmpegPath,
CancellationToken cancellationToken);
}
@@ -8,5 +8,6 @@ public interface IJellyfinMovieLibraryScanner
string address,
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath);
}
@@ -8,5 +8,6 @@ public interface IJellyfinTelevisionLibraryScanner
string address,
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath);
}
@@ -4,8 +4,13 @@ namespace ErsatzTV.Core.Interfaces.Metadata;
public interface ILocalStatisticsProvider
{
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem, string mediaItemPath);
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
Task<Either<BaseError, bool>> RefreshStatistics(
string ffmpegPath,
string ffprobePath,
MediaItem mediaItem,
string mediaItemPath);
Task<Either<BaseError, Dictionary<string, string>>> GetFormatTags(string ffprobePath, MediaItem mediaItem);
}
@@ -6,7 +6,9 @@ public interface IMovieFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax);
decimal progressMax,
CancellationToken cancellationToken);
}
@@ -6,7 +6,9 @@ public interface IMusicVideoFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax);
decimal progressMax,
CancellationToken cancellationToken);
}
@@ -6,6 +6,7 @@ public interface IOtherVideoFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax);
@@ -9,5 +9,6 @@ public interface ISongFolderScanner
string ffprobePath,
string ffmpegPath,
decimal progressMin,
decimal progressMax);
decimal progressMax,
CancellationToken cancellationToken);
}
@@ -6,7 +6,9 @@ public interface ITelevisionFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax);
decimal progressMax,
CancellationToken cancellationToken);
}
@@ -9,5 +9,6 @@ public interface IPlexMovieLibraryScanner
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
string ffmpegPath,
string ffprobePath);
}
@@ -9,5 +9,6 @@ public interface IPlexTelevisionLibraryScanner
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
string ffmpegPath,
string ffprobePath);
}
@@ -51,6 +51,7 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
string address,
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath)
{
List<JellyfinItemEtag> existingMovies = await _movieRepository.GetExistingJellyfinMovies(library);
@@ -172,7 +173,11 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
await _localStatisticsProvider.RefreshStatistics(
ffmpegPath,
ffprobePath,
incomingMovie,
localPath);
await refreshResult.Match(
async _ =>
@@ -51,6 +51,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
string address,
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath)
{
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
@@ -70,7 +71,15 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
await maybeShows.Match(
async shows =>
{
await ProcessShows(address, apiKey, library, ffprobePath, pathReplacements, existingShows, shows);
await ProcessShows(
address,
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
existingShows,
shows);
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
@@ -104,6 +113,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
string address,
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath,
List<JellyfinPathReplacement> pathReplacements,
List<JellyfinItemEtag> existingShows,
@@ -167,6 +177,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
address,
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
@@ -196,6 +207,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
string address,
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath,
List<JellyfinPathReplacement> pathReplacements,
JellyfinShow show,
@@ -286,6 +298,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
@@ -319,6 +332,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
string showName,
string seasonName,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath,
List<JellyfinPathReplacement> pathReplacements,
JellyfinSeason season,
@@ -408,7 +422,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, incomingEpisode, localPath);
refreshResult.Match(
_ => { },
+15 -6
View File
@@ -1,5 +1,6 @@
using System.Diagnostics;
using Bugsnag;
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
@@ -79,6 +80,7 @@ public abstract class LocalFolderScanner
protected async Task<Either<BaseError, MediaItemScanResult<T>>> UpdateStatistics<T>(
MediaItemScanResult<T> mediaItem,
string ffmpegPath,
string ffprobePath)
where T : MediaItem
{
@@ -92,7 +94,7 @@ public abstract class LocalFolderScanner
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem.Item);
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, mediaItem.Item);
refreshResult.Match(
result =>
{
@@ -123,7 +125,8 @@ public abstract class LocalFolderScanner
Domain.Metadata metadata,
ArtworkKind artworkKind,
Option<string> ffmpegPath,
Option<int> attachedPicIndex)
Option<int> attachedPicIndex,
CancellationToken cancellationToken)
{
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
@@ -167,8 +170,11 @@ public abstract class LocalFolderScanner
artworkFile,
picIndex,
tempName);
process.Start();
await process.WaitForExitAsync();
await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(cancellationToken);
return tempName;
},
@@ -177,8 +183,11 @@ public abstract class LocalFolderScanner
// no attached pic index means convert to png
string tempName = _tempFilePool.GetNextTempFile(TempFileCategory.CoverArt);
using Process process = ffmpegProcessService.ConvertToPng(path, artworkFile, tempName);
process.Start();
await process.WaitForExitAsync();
await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(cancellationToken);
return tempName;
});
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
@@ -30,12 +31,12 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
_logger = logger;
}
public async Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
public async Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem)
{
try
{
string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
return await RefreshStatistics(ffprobePath, mediaItem, filePath);
return await RefreshStatistics(ffmpegPath, ffprobePath, mediaItem, filePath);
}
catch (Exception ex)
{
@@ -46,6 +47,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
}
public async Task<Either<BaseError, bool>> RefreshStatistics(
string ffmpegPath,
string ffprobePath,
MediaItem mediaItem,
string mediaItemPath)
@@ -57,6 +59,11 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
async ffprobe =>
{
MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe);
if (version.Duration.TotalSeconds < 1)
{
await AnalyzeDuration(ffmpegPath, mediaItemPath, version);
}
bool result = await ApplyVersionUpdate(mediaItem, version, mediaItemPath);
return Right<BaseError, bool>(result);
},
@@ -185,6 +192,67 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
});
}
private async Task AnalyzeDuration(string ffmpegPath, string path, MediaVersion version)
{
try
{
_logger.LogInformation(
"Media item at {Path} is missing duration metadata and requires additional analysis",
path);
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(path);
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("null");
startInfo.ArgumentList.Add("-");
var probe = new Process
{
StartInfo = startInfo
};
probe.Start();
string output = await probe.StandardError.ReadToEndAsync();
await probe.WaitForExitAsync();
if (probe.ExitCode == 0)
{
const string PATTERN = @"time=([^ ]+)";
IEnumerable<string> reversed = output.Split("\n").Reverse();
foreach (string line in reversed)
{
Match match = Regex.Match(line, PATTERN);
if (match.Success)
{
string time = match.Groups[1].Value;
var duration = TimeSpan.Parse(time, NumberFormatInfo.InvariantInfo);
_logger.LogInformation("Analyzed duration is {Duration}", duration);
version.Duration = duration;
return;
}
}
}
else
{
_logger.LogError("Duration analysis failed for media item at {Path}", path);
}
}
catch (Exception ex)
{
_client.Notify(ex);
_logger.LogError("Duration analysis failed for media item at {Path}", path);
}
}
internal MediaVersion ProjectToMediaVersion(string path, FFprobe probeOutput) =>
Optional(probeOutput)
.Filter(json => json?.format != null && json.streams != null)
@@ -210,13 +278,13 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
var seconds = TimeSpan.FromSeconds(duration);
version.Duration = seconds;
}
else
{
_logger.LogWarning(
"Media item at {Path} has a missing or invalid duration {Duration} and will cause scheduling issues",
path,
json.format.duration);
}
// else
// {
// _logger.LogWarning(
// "Media item at {Path} has a missing or invalid duration {Duration} and will cause scheduling issues",
// path,
// json.format.duration);
// }
foreach (FFprobeStream audioStream in json.streams.Filter(s => s.codec_type == "audio"))
{
+9 -6
View File
@@ -64,9 +64,11 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax)
decimal progressMax,
CancellationToken cancellationToken)
{
decimal progressSpread = progressMax - progressMin;
@@ -131,10 +133,10 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
.GetOrAdd(libraryPath, file)
.BindT(movie => UpdateStatistics(movie, ffprobePath))
.BindT(movie => UpdateStatistics(movie, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata)
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken))
.BindT(FlagNormal);
await maybeMovie.Match(
@@ -228,7 +230,8 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateArtwork(
MediaItemScanResult<Movie> result,
ArtworkKind artworkKind)
ArtworkKind artworkKind,
CancellationToken cancellationToken)
{
try
{
@@ -237,7 +240,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
async posterFile =>
{
MovieMetadata metadata = movie.MovieMetadata.Head();
await RefreshArtwork(posterFile, metadata, artworkKind, None, None);
await RefreshArtwork(posterFile, metadata, artworkKind, None, None, cancellationToken);
});
return result;
@@ -65,9 +65,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax)
decimal progressMax,
CancellationToken cancellationToken)
{
decimal progressSpread = progressMax - progressMin;
@@ -87,17 +89,19 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
await FindOrCreateArtist(libraryPath.Id, artistFolder)
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt));
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail, cancellationToken))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken));
await maybeArtist.Match(
async result =>
{
await ScanMusicVideos(
libraryPath,
ffmpegPath,
ffprobePath,
result.Item,
artistFolder);
artistFolder,
cancellationToken);
if (result.IsAdded)
{
@@ -210,7 +214,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> UpdateArtworkForArtist(
MediaItemScanResult<Artist> result,
string artistFolder,
ArtworkKind artworkKind)
ArtworkKind artworkKind,
CancellationToken cancellationToken)
{
try
{
@@ -219,7 +224,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
async artworkFile =>
{
ArtistMetadata metadata = artist.ArtistMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None, cancellationToken);
});
return result;
@@ -233,9 +238,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
private async Task ScanMusicVideos(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
Artist artist,
string artistFolder)
string artistFolder,
CancellationToken cancellationToken)
{
var folderQueue = new Queue<string>();
folderQueue.Enqueue(artistFolder);
@@ -272,9 +279,9 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
// TODO: figure out how to rebuild playouts
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo = await _musicVideoRepository
.GetOrAdd(artist, libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(musicVideo => UpdateStatistics(musicVideo, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail)
.BindT(result => UpdateThumbnail(result, cancellationToken))
.BindT(FlagNormal);
await maybeMusicVideo.Match(
@@ -376,7 +383,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateThumbnail(
MediaItemScanResult<MusicVideo> result)
MediaItemScanResult<MusicVideo> result,
CancellationToken cancellationToken)
{
try
{
@@ -385,7 +393,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
async thumbnailFile =>
{
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None);
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken);
});
return result;
@@ -62,6 +62,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax)
@@ -126,7 +127,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
{
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata)
.BindT(FlagNormal);
+19 -10
View File
@@ -66,7 +66,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
string ffprobePath,
string ffmpegPath,
decimal progressMin,
decimal progressMax)
decimal progressMax,
CancellationToken cancellationToken)
{
decimal progressSpread = progressMax - progressMin;
@@ -128,9 +129,9 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
{
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(video => UpdateThumbnail(video, ffmpegPath))
.BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken))
.BindT(FlagNormal);
await maybeSong.Match(
@@ -212,7 +213,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateThumbnail(
MediaItemScanResult<Song> result,
string ffmpegPath)
string ffmpegPath,
CancellationToken cancellationToken)
{
try
{
@@ -234,10 +236,16 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
async thumbnailFile =>
{
SongMetadata metadata = song.SongMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, ffmpegPath, None);
await RefreshArtwork(
thumbnailFile,
metadata,
ArtworkKind.Thumbnail,
ffmpegPath,
None,
cancellationToken);
},
() => ExtractEmbeddedArtwork(song, ffmpegPath));
() => ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken));
return result;
}
catch (Exception ex)
@@ -246,7 +254,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateThumbnail(Song song)
{
string path = song.MediaVersions.Head().MediaFiles.Head().Path;
@@ -263,7 +271,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
}).Flatten();
}
private async Task ExtractEmbeddedArtwork(Song song, string ffmpegPath)
private async Task ExtractEmbeddedArtwork(Song song, string ffmpegPath, CancellationToken cancellationToken)
{
Option<MediaStream> maybeArtworkStream = Optional(song.GetHeadVersion().Streams.Find(ms => ms.AttachedPic));
foreach (MediaStream artworkStream in maybeArtworkStream)
@@ -273,7 +281,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
song.SongMetadata.Head(),
ArtworkKind.Thumbnail,
ffmpegPath,
artworkStream.Index);
artworkStream.Index,
cancellationToken);
}
}
}
@@ -64,9 +64,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax)
decimal progressMax,
CancellationToken cancellationToken)
{
decimal progressSpread = progressMax - progressMin;
@@ -84,18 +86,20 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
await FindOrCreateShow(libraryPath.Id, showFolder)
.BindT(show => UpdateMetadataForShow(show, showFolder))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail));
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster, cancellationToken))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt, cancellationToken))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken));
await maybeShow.Match(
async result =>
{
await ScanSeasons(
libraryPath,
ffmpegPath,
ffprobePath,
result.Item,
showFolder);
showFolder,
cancellationToken);
if (result.IsAdded)
{
@@ -154,9 +158,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
private async Task<Unit> ScanSeasons(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
Show show,
string showFolder)
string showFolder,
CancellationToken cancellationToken)
{
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder)
.OrderBy(identity))
@@ -179,12 +185,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
Either<BaseError, Season> maybeSeason = await _televisionRepository
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
.BindT(EnsureMetadataExists)
.BindT(season => UpdatePoster(season, seasonFolder));
.BindT(season => UpdatePoster(season, seasonFolder, cancellationToken));
await maybeSeason.Match(
async season =>
{
await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder);
await ScanEpisodes(libraryPath, ffmpegPath, ffprobePath, season, seasonFolder, cancellationToken);
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
season.Show = show;
@@ -206,9 +212,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
private async Task<Unit> ScanEpisodes(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
Season season,
string seasonPath)
string seasonPath,
CancellationToken cancellationToken)
{
var allSeasonFiles = _localFileSystem.ListSubdirectories(seasonPath)
.Map(_localFileSystem.ListFiles)
@@ -225,10 +233,10 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
.GetOrAddEpisode(season, libraryPath, file)
.BindT(
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffmpegPath, ffprobePath)
.MapT(_ => episode))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail)
.BindT(e => UpdateThumbnail(e, cancellationToken))
.BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e)))
.MapT(r => r.Item);
@@ -359,7 +367,8 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateArtworkForShow(
MediaItemScanResult<Show> result,
string showFolder,
ArtworkKind artworkKind)
ArtworkKind artworkKind,
CancellationToken cancellationToken)
{
try
{
@@ -368,7 +377,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
async artworkFile =>
{
ShowMetadata metadata = show.ShowMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None);
await RefreshArtwork(artworkFile, metadata, artworkKind, None, None, cancellationToken);
});
return result;
@@ -380,7 +389,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
}
}
private async Task<Either<BaseError, Season>> UpdatePoster(Season season, string seasonFolder)
private async Task<Either<BaseError, Season>> UpdatePoster(Season season, string seasonFolder, CancellationToken cancellationToken)
{
try
{
@@ -388,7 +397,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
async posterFile =>
{
SeasonMetadata metadata = season.SeasonMetadata.Head();
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None, None);
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster, None, None, cancellationToken);
});
return season;
@@ -400,7 +409,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
}
}
private async Task<Either<BaseError, Episode>> UpdateThumbnail(Episode episode)
private async Task<Either<BaseError, Episode>> UpdateThumbnail(Episode episode, CancellationToken cancellationToken)
{
try
{
@@ -409,7 +418,13 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
{
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail, None, None);
await RefreshArtwork(
posterFile,
metadata,
ArtworkKind.Thumbnail,
None,
None,
cancellationToken);
}
});
@@ -54,6 +54,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
string ffmpegPath,
string ffprobePath)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
@@ -93,7 +94,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
.BindT(existing => UpdateStatistics(pathReplacements, existing, incoming, ffprobePath))
.BindT(
existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath))
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateArtwork(existing, incoming));
@@ -145,6 +147,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
List<PlexPathReplacement> pathReplacements,
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming,
string ffmpegPath,
string ffprobePath)
{
PlexMovie existing = result.Item;
@@ -178,7 +181,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
await refreshResult.Match(
async _ =>
@@ -55,6 +55,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
string ffmpegPath,
string ffprobePath)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
@@ -82,7 +83,14 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
await maybeShow.Match(
async result =>
{
await ScanSeasons(library, pathReplacements, result.Item, connection, token, ffprobePath);
await ScanSeasons(
library,
pathReplacements,
result.Item,
connection,
token,
ffmpegPath,
ffprobePath);
if (result.IsAdded)
{
@@ -286,6 +294,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token,
string ffmpegPath,
string ffprobePath)
{
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
@@ -315,6 +324,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
season,
connection,
token,
ffmpegPath,
ffprobePath);
season.Show = show;
@@ -384,6 +394,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token,
string ffmpegPath,
string ffprobePath)
{
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
@@ -431,6 +442,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
library,
connection,
token,
ffmpegPath,
ffprobePath))
.BindT(existing => UpdateArtwork(existing, incoming));
@@ -503,6 +515,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token,
string ffmpegPath,
string ffprobePath)
{
MediaVersion existingVersion = existing.MediaVersions.Head();
@@ -535,7 +548,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
await refreshResult.Match(
async _ =>
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.5.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.17.1" />
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
@@ -93,6 +93,68 @@ public class PipelineGeneratorTests
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -re -stream_loop -1 -i http://localhost:8080/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
public void HlsDirect_Test()
{
var videoInputFile = new VideoInputFile(
"/tmp/whatever.mkv",
new List<VideoStream>
{ new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) });
var audioInputFile = new AudioInputFile(
"/tmp/whatever.mkv",
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
new AudioState(
AudioFormat.Copy,
None,
None,
None,
None,
None,
false));
var desiredState = new FrameState(
true,
false,
VideoFormat.Copy,
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<int>.None,
2000,
4000,
90_000,
false);
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
Option<TimeSpan>.None,
Option<TimeSpan>.None,
false,
Option<string>.None,
Option<string>.None,
Option<string>.None,
OutputFormatKind.MpegTs,
Option<string>.None,
Option<string>.None,
0);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, "", _logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0);
result.PipelineSteps.Should().Contain(ps => ps is EncoderCopyVideo);
result.PipelineSteps.Should().Contain(ps => ps is EncoderCopyAudio);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
command.Should().Be(
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
private static string PrintCommand(
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,
+3 -2
View File
@@ -7,8 +7,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
<PackageReference Include="CliWrap" Version="3.4.1" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
</ItemGroup>
<ItemGroup>
+5
View File
@@ -607,6 +607,11 @@ public class PipelineBuilder
private static bool IsDesiredVideoState(FrameState currentState, FrameState desiredState)
{
if (desiredState.VideoFormat == VideoFormat.Copy)
{
return true;
}
return currentState.VideoFormat == desiredState.VideoFormat &&
currentState.PixelFormat.Match(pf => pf.Name, () => string.Empty) ==
desiredState.PixelFormat.Match(pf => pf.Name, string.Empty) &&
@@ -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());
}
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)
{
}
}
}
+3 -3
View File
@@ -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>
+51 -44
View File
@@ -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(
+31 -24
View File
@@ -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");
}
}
}
+51 -44
View File
@@ -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(
+51 -44
View File
@@ -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(
+10 -2
View File
@@ -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);
+72 -56
View File
@@ -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");
}
}
}