Browse Source

ffmpeg and ffprobe validation fixes (#63)

* abort building playout if any collection contains a zero-duration item

* surface errors calling ffprobe

* improve ffmpeg/ffprobe path validation
pull/1213/head
Jason Dove 5 years ago committed by GitHub
parent
commit
62366d4da3
  1. 5
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettings.cs
  2. 67
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  3. 18
      ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs
  4. 3
      ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs
  5. 10
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  6. 24
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  7. 19
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  8. 15
      ErsatzTV/Pages/FFmpeg.razor
  9. 1
      ErsatzTV/Services/SchedulerService.cs

5
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettings.cs

@ -1,6 +1,7 @@
using MediatR; using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.FFmpegProfiles.Commands namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : IRequest; public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : MediatR.IRequest<Either<BaseError, Unit>>;
} }

67
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -1,21 +1,74 @@
using System.Threading; using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using MediatR;
using Unit = MediatR.Unit;
namespace ErsatzTV.Application.FFmpegProfiles.Commands namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings> public class UpdateFFmpegSettingsHandler : MediatR.IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
public UpdateFFmpegSettingsHandler(IConfigElementRepository configElementRepository) => public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem)
{
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
}
public Task<Either<BaseError, Unit>> Handle(
UpdateFFmpegSettings request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyUpdate(request))
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
(await FFmpegMustExist(request), await FFprobeMustExist(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{
if (!_localFileSystem.FileExists(path))
{
return BaseError.New($"{name} path does not exist");
}
var startInfo = new ProcessStartInfo
{
FileName = path,
Arguments = "-version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
var test = new Process
{
StartInfo = startInfo
};
test.Start();
string output = await test.StandardOutput.ReadToEndAsync();
await test.WaitForExitAsync();
return test.ExitCode == 0 && output.Contains($"{name} version")
? Unit.Default
: BaseError.New($"Unable to verify {name} version");
}
public async Task<Unit> Handle(UpdateFFmpegSettings request, CancellationToken cancellationToken) private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
{ {
Option<ConfigElement> ffmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath); Option<ConfigElement> ffmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
Option<ConfigElement> ffprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath); Option<ConfigElement> ffprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath);
@ -64,7 +117,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
_configElementRepository.Add(ce); _configElementRepository.Add(ce);
}); });
return Unit.Value; return Unit.Default;
} }
} }
} }

18
ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs

@ -37,6 +37,24 @@ namespace ErsatzTV.Core.Tests.Scheduling
_logger = factory.CreateLogger<PlayoutBuilder>(); _logger = factory.CreateLogger<PlayoutBuilder>();
} }
[Test]
[Timeout(2000)]
public async Task ZeroDurationItem_Should_Abort()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.Zero, DateTime.Today)
};
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Random);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Should().BeNull();
}
[Test] [Test]
public async Task InitialFlood_Should_StartAtMidnight() public async Task InitialFlood_Should_StartAtMidnight()
{ {

3
ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs

@ -1,10 +1,11 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata namespace ErsatzTV.Core.Interfaces.Metadata
{ {
public interface ILocalStatisticsProvider public interface ILocalStatisticsProvider
{ {
Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem); Task<Either<BaseError, Unit>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
} }
} }

10
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -80,7 +80,15 @@ namespace ErsatzTV.Core.Metadata
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path)) if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
{ {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); Either<BaseError, Unit> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
refreshResult.IfLeft(
error =>
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
path,
error.Value));
} }
return mediaItem; return mediaItem;

24
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -29,7 +29,7 @@ namespace ErsatzTV.Core.Metadata
_logger = logger; _logger = logger;
} }
public async Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem) public async Task<Either<BaseError, Unit>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
{ {
try try
{ {
@ -40,14 +40,20 @@ namespace ErsatzTV.Core.Metadata
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) _ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
}; };
FFprobe ffprobe = await GetProbeOutput(ffprobePath, filePath); Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, filePath);
MediaVersion version = ProjectToMediaVersion(ffprobe); return await maybeProbe.Match(
return await ApplyVersionUpdate(mediaItem, version, filePath); async ffprobe =>
{
MediaVersion version = ProjectToMediaVersion(ffprobe);
await ApplyVersionUpdate(mediaItem, version, filePath);
return Right<BaseError, Unit>(Unit.Default);
},
error => Task.FromResult(Left<BaseError, Unit>(error)));
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id); _logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
return false; return BaseError.New(ex.Message);
} }
} }
@ -76,7 +82,7 @@ namespace ErsatzTV.Core.Metadata
return await _mediaItemRepository.Update(mediaItem) && durationChange; return await _mediaItemRepository.Update(mediaItem) && durationChange;
} }
private Task<FFprobe> GetProbeOutput(string ffprobePath, string filePath) private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
{ {
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
@ -101,11 +107,13 @@ namespace ErsatzTV.Core.Metadata
}; };
probe.Start(); probe.Start();
return probe.StandardOutput.ReadToEndAsync().MapAsync( return probe.StandardOutput.ReadToEndAsync().MapAsync<string, Either<BaseError, FFprobe>>(
async output => async output =>
{ {
await probe.WaitForExitAsync(); await probe.WaitForExitAsync();
return JsonConvert.DeserializeObject<FFprobe>(output); return probe.ExitCode == 0
? JsonConvert.DeserializeObject<FFprobe>(output)
: BaseError.New($"FFprobe at {ffprobePath} exited with code {probe.ExitCode}");
}); });
} }

19
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -88,6 +88,25 @@ namespace ErsatzTV.Core.Scheduling
return playout; return playout;
} }
Option<CollectionKey> zeroDurationCollection = collectionMediaItems.Find(
c => c.Value.Any(
mi => mi switch
{
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
_ => true
})).Map(c => c.Key);
if (zeroDurationCollection.IsSome)
{
_logger.LogError(
"Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!",
zeroDurationCollection.ValueUnsafe());
return playout;
}
playout.Items ??= new List<PlayoutItem>(); playout.Items ??= new List<PlayoutItem>();
playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>(); playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>();

15
ErsatzTV/Pages/FFmpeg.razor

@ -2,8 +2,11 @@
@using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.FFmpegProfiles.Commands @using ErsatzTV.Application.FFmpegProfiles.Commands
@using ErsatzTV.Application.FFmpegProfiles.Queries @using ErsatzTV.Application.FFmpegProfiles.Queries
@using Unit = LanguageExt.Unit
@inject IDialogService Dialog @inject IDialogService Dialog
@inject IMediator Mediator @inject IMediator Mediator
@inject ILogger<FFmpeg> Logger
@inject ISnackbar Snackbar
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudCard> <MudCard>
@ -110,7 +113,17 @@
await LoadFFmpegProfilesAsync(); await LoadFFmpegProfilesAsync();
} }
private Task SaveSettings() => Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings)); private async Task SaveSettings()
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings));
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
},
Right: _ => Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success));
}
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null; private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;

1
ErsatzTV/Services/SchedulerService.cs

@ -56,7 +56,6 @@ namespace ErsatzTV.Services
await ScanLocalMediaSources(cancellationToken); await ScanLocalMediaSources(cancellationToken);
} }
private async Task BuildPlayouts(CancellationToken cancellationToken) private async Task BuildPlayouts(CancellationToken cancellationToken)
{ {
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();

Loading…
Cancel
Save