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/65/head
Jason Dove 4 years ago committed by GitHub
parent
commit
1587ac7d62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  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 @@ @@ -1,6 +1,7 @@
using MediatR;
using ErsatzTV.Core;
using LanguageExt;
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 @@ @@ -1,21 +1,74 @@
using System.Threading;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Unit = MediatR.Unit;
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 ILocalFileSystem _localFileSystem;
public UpdateFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem)
{
_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> ffprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath);
@ -64,7 +117,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands @@ -64,7 +117,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
_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 @@ -37,6 +37,24 @@ namespace ErsatzTV.Core.Tests.Scheduling
_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]
public async Task InitialFlood_Should_StartAtMidnight()
{

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

@ -1,10 +1,11 @@ @@ -1,10 +1,11 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
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 @@ -80,7 +80,15 @@ namespace ErsatzTV.Core.Metadata
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(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;

24
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -29,7 +29,7 @@ namespace ErsatzTV.Core.Metadata @@ -29,7 +29,7 @@ namespace ErsatzTV.Core.Metadata
_logger = logger;
}
public async Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
public async Task<Either<BaseError, Unit>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
{
try
{
@ -40,14 +40,20 @@ namespace ErsatzTV.Core.Metadata @@ -40,14 +40,20 @@ namespace ErsatzTV.Core.Metadata
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
FFprobe ffprobe = await GetProbeOutput(ffprobePath, filePath);
MediaVersion version = ProjectToMediaVersion(ffprobe);
return await ApplyVersionUpdate(mediaItem, version, filePath);
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, filePath);
return await maybeProbe.Match(
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)
{
_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 @@ -76,7 +82,7 @@ namespace ErsatzTV.Core.Metadata
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
{
@ -101,11 +107,13 @@ namespace ErsatzTV.Core.Metadata @@ -101,11 +107,13 @@ namespace ErsatzTV.Core.Metadata
};
probe.Start();
return probe.StandardOutput.ReadToEndAsync().MapAsync(
return probe.StandardOutput.ReadToEndAsync().MapAsync<string, Either<BaseError, FFprobe>>(
async output =>
{
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 @@ -88,6 +88,25 @@ namespace ErsatzTV.Core.Scheduling
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.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>();

15
ErsatzTV/Pages/FFmpeg.razor

@ -2,8 +2,11 @@ @@ -2,8 +2,11 @@
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.FFmpegProfiles.Commands
@using ErsatzTV.Application.FFmpegProfiles.Queries
@using Unit = LanguageExt.Unit
@inject IDialogService Dialog
@inject IMediator Mediator
@inject ILogger<FFmpeg> Logger
@inject ISnackbar Snackbar
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudCard>
@ -110,7 +113,17 @@ @@ -110,7 +113,17 @@
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;

1
ErsatzTV/Services/SchedulerService.cs

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

Loading…
Cancel
Save