Compare commits

...

8 Commits

Author SHA1 Message Date
Jason Dove
60965d0961 update changelog for release v0.5.1-beta [no ci] 2022-04-17 17:36:32 -05:00
Jason Dove
ff1a7b376f add empty trash button (#739) 2022-04-17 14:45:49 -05:00
Jason Dove
741b00fd52 fix multiple filler scheduling bugs (#738) 2022-04-17 13:30:47 -05:00
Jason Dove
7e55681916 fix cliwrap usage (#737) 2022-04-16 20:12:18 -05:00
Jason Dove
210630d299 subtitle fixes for software, videotoolbox, vaapi (#736)
* fix subtitles using software encoders

* videotoolbox fixes

* fix some vaapi subtitle edge cases
2022-04-16 16:06:49 -05:00
Jason Dove
0ddbb898d6 fix subtitle stream selection (#735) 2022-04-15 19:40:08 -05:00
Jason Dove
d6bf579436 fix alpha => beta versioning 2022-04-15 09:08:30 -05:00
Jason Dove
765df64555 add picture subtitle transcoding tests, and make them all pass with nvenc (#734) 2022-04-14 22:30:26 -05:00
34 changed files with 549 additions and 191 deletions

View File

@@ -19,14 +19,14 @@ jobs:
tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}"
short=$(git rev-parse --short HEAD)
final="${tag2/alpha/$short}"
final="${tag2/beta/$short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Extract Artifacts Version
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
short=$(git rev-parse --short HEAD)
final="${tag/alpha/$short}"
final="${tag/beta/$short}"
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
outputs:

View File

@@ -16,7 +16,7 @@ jobs:
run: |
tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-alpha/}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-beta/}" >> $GITHUB_ENV
- name: Extract Artifacts Version
shell: bash
run: |

View File

@@ -5,6 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.5.1-beta] - 2022-04-17
### Fixed
- Fix subtitles edge case with NVENC
- Only select picture subtitles (text subtitles are not yet supported)
- Supported picture subtitles are `hdmv_pgs_subtitle` and `dvd_subtitle`
- Fix subtitles using software encoders, videotoolbox, VAAPI
- Fix setting VAAPI driver name
- Fix ffmpeg troubleshooting reports
- Fix bug where filler would behave as if it were configured to pad even though a different mode was selected
- Fix bug where mid-roll count filler would skip scheduling the final chapter in an episode
### Added
- Add `Empty Trash` button to `Trash` page
## [0.5.0-beta] - 2022-04-13
### Fixed
- Fix `HLS Segmenter` bug where it would drift off of the schedule if a playout was changed while the segmenter was running
@@ -1068,7 +1082,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.5.0-beta...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.1-beta...HEAD
[0.5.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.0-beta...v0.5.1-beta
[0.5.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.5-alpha...v0.5.0-beta
[0.4.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.4-alpha...v0.4.5-alpha
[0.4.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.3-alpha...v0.4.4-alpha

View File

@@ -1,5 +1,4 @@
using System.Diagnostics;
using CliWrap;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -70,16 +69,13 @@ public class
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
Process process = _ffmpegProcessService.ResizeImage(
Command process = _ffmpegProcessService.ResizeImage(
ffmpegPath,
originalPath,
withExtension,
request.MaxHeight.Value);
CommandResult resize = await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync();
CommandResult resize = await process.ExecuteAsync();
if (resize.ExitCode != 0)
{

View File

@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Maintenance;
public record EmptyTrash : IRequest<Either<BaseError, Unit>>;

View File

@@ -0,0 +1,55 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Maintenance;
public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, Unit>>
{
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISearchIndex _searchIndex;
public EmptyTrashHandler(
IMediaItemRepository mediaItemRepository,
ISearchIndex searchIndex)
{
_mediaItemRepository = mediaItemRepository;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
EmptyTrash request,
CancellationToken cancellationToken)
{
string[] types =
{
SearchIndex.MovieType,
SearchIndex.ShowType,
SearchIndex.SeasonType,
SearchIndex.EpisodeType,
SearchIndex.MusicVideoType,
SearchIndex.OtherVideoType,
SearchIndex.SongType,
SearchIndex.ArtistType
};
var ids = new List<int>();
foreach (string type in types)
{
SearchResult result = await _searchIndex.Search($"type:{type} AND (state:FileNotFound)", 0, 0);
ids.AddRange(result.Items.Map(i => i.Id));
}
Either<BaseError, Unit> deleteResult = await _mediaItemRepository.DeleteItems(ids);
if (deleteResult.IsRight)
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
}
return deleteResult;
}
}

View File

@@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Text;
using System.Timers;
using Bugsnag;
using CliWrap;
@@ -207,18 +207,15 @@ public class HlsSessionWorker : IHlsSessionWorker
{
await TrimAndDelete(cancellationToken);
using Process process = processModel.Process;
Command process = processModel.Process;
_logger.LogInformation(
"ffmpeg hls arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
_logger.LogInformation("ffmpeg hls arguments {FFmpegArguments}", process.Arguments);
try
{
BufferedCommandResult commandResult = await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
BufferedCommandResult commandResult = await process
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
if (commandResult.ExitCode == 0)
{
@@ -256,16 +253,15 @@ public class HlsSessionWorker : IHlsSessionWorker
foreach (PlayoutItemProcessModel errorProcessModel in maybeOfflineProcess.RightAsEnumerable())
{
Process errorProcess = errorProcessModel.Process;
Command errorProcess = errorProcessModel.Process;
_logger.LogInformation(
"ffmpeg hls error arguments {FFmpegArguments}",
string.Join(" ", errorProcess.StartInfo.ArgumentList));
errorProcess.Arguments);
commandResult = await Cli.Wrap(errorProcess.StartInfo.FileName)
.WithArguments(errorProcess.StartInfo.ArgumentList)
commandResult = await errorProcess
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
if (commandResult.ExitCode == 0)
{

View File

@@ -1,5 +1,8 @@
using System.Diagnostics;
using CliWrap;
namespace ErsatzTV.Application.Streaming;
public record PlayoutItemProcessModel(Process Process, Option<TimeSpan> MaybeDuration, DateTimeOffset Until);
public record PlayoutItemProcessModel(
Command Process,
Option<TimeSpan> MaybeDuration,
DateTimeOffset Until);

View File

@@ -1,4 +1,4 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -32,7 +32,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Process process = _ffmpegProcessService.ConcatChannel(
Command process = _ffmpegProcessService.ConcatChannel(
ffmpegPath,
saveReports,
channel,

View File

@@ -1,4 +1,4 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -27,7 +27,7 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
string ffprobePath,
CancellationToken cancellationToken)
{
Process process = await _ffmpegProcessService.ForError(
Command process = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
request.MaybeDuration,

View File

@@ -1,4 +1,4 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
@@ -139,7 +139,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Process process = await _ffmpegProcessService.ForPlayoutItem(
Command process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
saveReports,
@@ -185,7 +185,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
switch (error)
{
case UnableToLocatePlayoutItem:
Process offlineProcess = await _ffmpegProcessService.ForError(
Command offlineProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
@@ -195,7 +195,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return new PlayoutItemProcessModel(offlineProcess, maybeDuration, finish);
case PlayoutItemDoesNotExistOnDisk:
Process doesNotExistProcess = await _ffmpegProcessService.ForError(
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
@@ -205,7 +205,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return new PlayoutItemProcessModel(doesNotExistProcess, maybeDuration, finish);
default:
Process errorProcess = await _ffmpegProcessService.ForError(
Command errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,

View File

@@ -1,4 +1,4 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -32,7 +32,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Process process = _ffmpegProcessService.WrapSegmenter(
Command process = _ffmpegProcessService.WrapSegmenter(
ffmpegPath,
saveReports,
channel,

View File

@@ -11,6 +11,7 @@
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
<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.1" />
@@ -36,6 +37,9 @@
<Content Include="Resources\ErsatzTV.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\test.sup">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -65,6 +65,12 @@ public class TranscodingTests
// TODO: animated vs static
}
public enum Subtitle
{
None,
Picture
}
private class TestData
{
public static Watermark[] Watermarks =
@@ -73,6 +79,12 @@ public class TranscodingTests
Watermark.PermanentOpaque,
Watermark.PermanentTransparent
};
public static Subtitle[] Subtitles =
{
Subtitle.None,
Subtitle.Picture
};
public static Padding[] Paddings =
{
@@ -158,6 +170,7 @@ public class TranscodingTests
[ValueSource(typeof(TestData), nameof(TestData.Paddings))] Padding padding,
[ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))] VideoScanKind videoScanKind,
[ValueSource(typeof(TestData), nameof(TestData.Watermarks))] Watermark watermark,
[ValueSource(typeof(TestData), nameof(TestData.Subtitles))] Subtitle subtitle,
[ValueSource(typeof(TestData), nameof(TestData.VideoFormats))] FFmpegProfileVideoFormat profileVideoFormat,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
@@ -175,7 +188,7 @@ public class TranscodingTests
}
string name = GetStringSha256Hash(
$"{inputFormat.Encoder}_{inputFormat.PixelFormat}_{videoScanKind}_{padding}_{profileResolution}_{profileVideoFormat}_{profileAcceleration}");
$"{inputFormat.Encoder}_{inputFormat.PixelFormat}_{videoScanKind}_{padding}_{watermark}_{subtitle}_{profileResolution}_{profileVideoFormat}_{profileAcceleration}");
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
if (!File.Exists(file))
@@ -203,6 +216,46 @@ public class TranscodingTests
// ReSharper disable once MethodHasAsyncOverload
p1.WaitForExit();
p1.ExitCode.Should().Be(0);
switch (subtitle)
{
case Subtitle.Picture:
string sourceFile = Path.GetTempFileName() + ".mkv";
File.Move(file, sourceFile, true);
string tempFileName = Path.GetTempFileName() + ".mkv";
string subPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "test.sup");
var p2 = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = ExecutableName("mkvmerge"),
Arguments = $"-o {tempFileName} {sourceFile} {subPath}"
}
};
p2.Start();
await p2.WaitForExitAsync();
// ReSharper disable once MethodHasAsyncOverload
p2.WaitForExit();
if (p2.ExitCode != 0)
{
if (File.Exists(sourceFile))
{
File.Delete(sourceFile);
}
if (File.Exists(file))
{
File.Delete(file);
}
}
p2.ExitCode.Should().Be(0);
File.Move(tempFileName, file, true);
break;
}
}
var imageCache = new Mock<IImageCache>();
@@ -221,7 +274,7 @@ public class TranscodingTests
imageCache.Object,
new Mock<ITempFilePool>().Object,
new Mock<IClient>().Object,
new Mock<IMemoryCache>().Object,
new MemoryCache(new MemoryCacheOptions()),
LoggerFactory.CreateLogger<FFmpegProcessService>());
var service = new FFmpegLibraryProcessService(
@@ -318,7 +371,13 @@ public class TranscodingTests
break;
}
using Process process = await service.ForPlayoutItem(
ChannelSubtitleMode subtitleMode = subtitle switch
{
Subtitle.Picture => ChannelSubtitleMode.Any,
_ => ChannelSubtitleMode.None
};
Command process = await service.ForPlayoutItem(
ExecutableName("ffmpeg"),
ExecutableName("ffprobe"),
false,
@@ -329,9 +388,11 @@ public class TranscodingTests
{
HardwareAcceleration = profileAcceleration,
VideoFormat = profileVideoFormat,
AudioFormat = FFmpegProfileAudioFormat.Aac
AudioFormat = FFmpegProfileAudioFormat.Aac,
DeinterlaceVideo = true
},
StreamingMode = StreamingMode.TransportStream
StreamingMode = StreamingMode.TransportStream,
SubtitleMode = subtitleMode
},
v,
v,
@@ -365,16 +426,14 @@ public class TranscodingTests
var timeoutSignal = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
result = await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
result = await process
.WithStandardOutputPipe(PipeTarget.ToStream(Stream.Null))
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(sb))
.ExecuteAsync(timeoutSignal.Token);
}
catch (OperationCanceledException)
{
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
Assert.Fail($"Transcode failure (timeout): ffmpeg {string.Join(" ", quotedArgs)}");
Assert.Fail($"Transcode failure (timeout): ffmpeg {process.Arguments}");
return;
}
@@ -383,22 +442,19 @@ public class TranscodingTests
if (profileAcceleration != HardwareAccelerationKind.None && isUnsupported)
{
var quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'").ToList();
result.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
Assert.Warn($"Unsupported on this hardware: ffmpeg {string.Join(" ", quotedArgs)}");
result.ExitCode.Should().Be(1, $"Error message with successful exit code? {process.Arguments}");
Assert.Warn($"Unsupported on this hardware: ffmpeg {process.Arguments}");
}
else if (error.Contains("Impossible to convert between"))
{
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
Assert.Fail($"Transcode failure: ffmpeg {string.Join(" ", quotedArgs)}");
Assert.Fail($"Transcode failure: ffmpeg {process.Arguments}");
}
else
{
var quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'").ToList();
result.ExitCode.Should().Be(0, error + Environment.NewLine + string.Join(" ", quotedArgs));
result.ExitCode.Should().Be(0, error + Environment.NewLine + process.Arguments);
if (result.ExitCode == 0)
{
Console.WriteLine(string.Join(" ", quotedArgs));
Console.WriteLine(process.Arguments);
}
}
}
@@ -425,7 +481,7 @@ public class TranscodingTests
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
public Task<Option<MediaStream>> SelectSubtitleStream(Channel channel, MediaVersion version) =>
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Subtitle)).AsTask();
Optional(version.Streams.Find(s => s.MediaStreamKind == MediaStreamKind.Subtitle)).AsTask();
}
private static string ExecutableName(string baseName) =>

Binary file not shown.

View File

@@ -3,8 +3,10 @@ using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Serilog;
namespace ErsatzTV.Core.Tests.Scheduling;
@@ -222,7 +224,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
List<PlayoutItem> playoutItems = PlayoutModeSchedulerBase<ProgramScheduleItem>
List<PlayoutItem> playoutItems = Scheduler()
.AddFiller(
startState,
CollectionEnumerators(scheduleItem, enumerator),
@@ -273,7 +275,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
// too lazy to make another enumerator for the filler that we don't want
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), enumerator);
List<PlayoutItem> playoutItems = PlayoutModeSchedulerBase<ProgramScheduleItem>
List<PlayoutItem> playoutItems = Scheduler()
.AddFiller(
startState,
enumerators,
@@ -283,6 +285,77 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
playoutItems.Count.Should().Be(1);
}
[Test]
public void Should_Schedule_Mid_Roll_Count_Filler_Correctly()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(5));
var scheduleItem = new ProgramScheduleItemOne
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Count,
PadToNearestMinute = 60, // this should be ignored
Count = 1
}
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
new List<ProgramScheduleItem> { scheduleItem },
new CollectionEnumeratorState());
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var fillerEnumerator = new ChronologicalMediaCollectionEnumerator(
collectionTwo.MediaItems,
new CollectionEnumeratorState());
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators = CollectionEnumerators(
scheduleItem,
enumerator);
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), fillerEnumerator);
List<PlayoutItem> playoutItems = Scheduler()
.AddFiller(
startState,
enumerators,
scheduleItem,
new PlayoutItem
{
MediaItemId = 1,
Start = startState.CurrentTime.UtcDateTime,
Finish = startState.CurrentTime.AddHours(1).UtcDateTime
},
new List<MediaChapter>
{
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
});
playoutItems.Count.Should().Be(3);
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
playoutItems[1].MediaItemId.Should().Be(3);
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(6));
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
}
}
[TestFixture]
@@ -324,4 +397,34 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { Duration = duration }
}
};
private static PlayoutModeSchedulerBase<ProgramScheduleItem> Scheduler() =>
new TestScheduler();
private class TestScheduler : PlayoutModeSchedulerBase<ProgramScheduleItem>
{
private static readonly ILoggerFactory LoggerFactory;
static TestScheduler()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();
LoggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
}
public TestScheduler() : base(LoggerFactory.CreateLogger<TestScheduler>())
{
}
public override Tuple<PlayoutBuilderState, List<PlayoutItem>> Schedule(
PlayoutBuilderState playoutBuilderState,
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
ProgramScheduleItem scheduleItem,
ProgramScheduleItem nextScheduleItem,
DateTimeOffset hardStop) =>
throw new NotSupportedException();
}
}

View File

@@ -1,5 +1,4 @@
using System.Diagnostics;
using System.Text;
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -32,7 +31,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_logger = logger;
}
public async Task<Process> ForPlayoutItem(
public async Task<Command> ForPlayoutItem(
string ffmpegPath,
string ffprobePath,
bool saveReports,
@@ -226,7 +225,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
return GetProcess(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline);
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline);
}
private Option<WatermarkInputFile> GetWatermarkInputFile(
@@ -290,7 +289,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return None;
}
public Task<Process> ForError(
public Task<Command> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
@@ -299,7 +298,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
long ptsOffset) =>
_ffmpegProcessService.ForError(ffmpegPath, channel, duration, errorMessage, hlsRealtime, ptsOffset);
public Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
public Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
@@ -319,13 +318,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
concatInputFile,
FFmpegState.Concat(saveReports, channel.Name));
return GetProcess(ffmpegPath, None, None, None, concatInputFile, pipeline);
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
_ffmpegProcessService.WrapSegmenter(ffmpegPath, saveReports, channel, scheme, host);
public Process ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
public Command ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
{
var videoInputFile = new VideoInputFile(
inputFile,
@@ -341,13 +340,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));
return GetProcess(ffmpegPath, videoInputFile, None, None, None, pipeline, false);
return GetCommand(ffmpegPath, videoInputFile, None, None, None, pipeline, false);
}
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile) =>
public Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile) =>
_ffmpegProcessService.ConvertToPng(ffmpegPath, inputFile, outputFile);
public Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile) =>
public Command ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile) =>
_ffmpegProcessService.ExtractAttachedPicAsPng(ffmpegPath, inputFile, streamIndex, outputFile);
public Task<Either<BaseError, string>> GenerateSongImage(
@@ -383,7 +382,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkWidthPercent,
cancellationToken);
private Process GetProcess(
private Command GetCommand(
string ffmpegPath,
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,
@@ -417,35 +416,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
concatInputFile,
pipeline.PipelineSteps);
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8
};
if (environmentVariables.Any())
{
_logger.LogDebug("FFmpeg environment variables {EnvVars}", environmentVariables);
}
foreach ((string key, string value) in environmentVariables)
{
startInfo.EnvironmentVariables[key] = value;
}
foreach (string argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
return new Process
{
StartInfo = startInfo
};
return Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null))
.WithEnvironmentVariables(environmentVariables.ToDictionary(e => e.Key, e => e.Value));
}
private static Option<string> VaapiDriverName(HardwareAccelerationMode accelerationMode, VaapiDriver driver)

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Text;
using Bugsnag;
using CliWrap;
using CliWrap.Buffered;
@@ -39,7 +40,7 @@ public class FFmpegProcessService
_logger = logger;
}
public async Task<Process> ForError(
public async Task<Command> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
@@ -102,29 +103,34 @@ public class FFmpegProcessService
await duration.IfSomeAsync(d => builder = builder.WithDuration(d));
switch (channel.StreamingMode)
Process process = channel.StreamingMode switch
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(
StreamingMode.HttpLiveStreamingSegmenter =>
builder.WithHls(
channel.Number,
None,
ptsOffset,
playbackSettings.VideoTrackTimeScale,
playbackSettings.FrameRate)
.Build();
default:
return builder.WithFormat("mpegts")
.WithPipe()
.Build();
}
.Build(),
_ => builder.WithFormat("mpegts")
.WithPipe()
.Build()
};
return Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
Process process = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -136,27 +142,45 @@ public class FFmpegProcessService
.WithFormat("mpegts")
.WithPipe()
.Build();
return Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
}
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
public Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
Process process = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithInput(inputFile)
.WithOutputFormat("apng", outputFile)
.Build();
return Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
}
public Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile)
public Command ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
Process process = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithInput(inputFile)
.WithMap($"0:{streamIndex}")
.WithOutputFormat("apng", outputFile)
.Build();
return Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
}
public async Task<Either<BaseError, string>> GenerateSongImage(
@@ -414,7 +438,7 @@ public class FFmpegProcessService
path
})
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync();
.ExecuteBufferedAsync(Encoding.UTF8);
if (result.ExitCode == 0)
{

View File

@@ -99,7 +99,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
var subtitleStreams = version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
// .Filter(s => s.Codec is "hdmv_pgs_subtitle" or "dvd_subtitle")
.Filter(s => s.Codec is "hdmv_pgs_subtitle" or "dvd_subtitle")
.ToList();
string language = (channel.PreferredSubtitleLanguageCode ?? string.Empty).ToLowerInvariant();

View File

@@ -1,4 +1,4 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.FFmpeg;
@@ -8,7 +8,7 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg;
public interface IFFmpegProcessService
{
Task<Process> ForPlayoutItem(
Task<Command> ForPlayoutItem(
string ffmpegPath,
string ffprobePath,
bool saveReports,
@@ -31,7 +31,7 @@ public interface IFFmpegProcessService
long ptsOffset,
Option<int> targetFramerate);
Task<Process> ForError(
Task<Command> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
@@ -39,15 +39,15 @@ public interface IFFmpegProcessService
bool hlsRealtime,
long ptsOffset);
Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Process ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
Command ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile);
Command ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile);
Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,

View File

@@ -1,5 +1,4 @@
using System.Diagnostics;
using Bugsnag;
using Bugsnag;
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
@@ -163,16 +162,13 @@ public abstract class LocalFolderScanner
{
// extract attached pic (and convert to png)
string tempName = _tempFilePool.GetNextTempFile(TempFileCategory.CoverArt);
using Process process = _ffmpegProcessService.ExtractAttachedPicAsPng(
Command process = _ffmpegProcessService.ExtractAttachedPicAsPng(
path,
artworkFile,
picIndex,
tempName);
await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(cancellationToken);
await process.ExecuteAsync(cancellationToken);
return tempName;
},
@@ -180,12 +176,9 @@ 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);
Command process = _ffmpegProcessService.ConvertToPng(path, artworkFile, tempName);
await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(cancellationToken);
await process.ExecuteAsync(cancellationToken);
return tempName;
});

View File

@@ -191,11 +191,9 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
.Append(Optional(scheduleItem.PostRollFiller))
.ToList();
if (allFiller.Count(f => f.PadToNearestMinute.HasValue) > 1)
// if (allFiller.Map(f => Optional(f.PadToNearestMinute)).Sequence().Flatten().Distinct().Count() > 1)
// multiple pad-to-nearest-minute values are invalid; use no filler
if (allFiller.Count(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue) > 1)
{
// multiple pad-to-nearest-minute values are invalid; use no filler
// TODO: log error?
return itemStartTime + itemDuration;
}
@@ -287,16 +285,17 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
}
}
foreach (FillerPreset padFiller in Optional(allFiller.FirstOrDefault(f => f.PadToNearestMinute.HasValue)))
foreach (FillerPreset padFiller in Optional(
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
{
int currentMinute = (itemStartTime + totalDuration).Minute;
// ReSharper disable once PossibleInvalidOperationException
int targetMinute = (currentMinute + padFiller.PadToNearestMinute.Value - 1) /
padFiller.PadToNearestMinute.Value * padFiller.PadToNearestMinute.Value;
DateTimeOffset targetTime = itemStartTime + totalDuration - TimeSpan.FromMinutes(currentMinute) +
TimeSpan.FromMinutes(targetMinute);
return new DateTimeOffset(
targetTime.Year,
targetTime.Month,
@@ -310,7 +309,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
return itemStartTime + totalDuration;
}
internal static List<PlayoutItem> AddFiller(
internal List<PlayoutItem> AddFiller(
PlayoutBuilderState playoutBuilderState,
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
ProgramScheduleItem scheduleItem,
@@ -324,11 +323,10 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
.Append(Optional(scheduleItem.PostRollFiller))
.ToList();
if (allFiller.Count(f => f.PadToNearestMinute.HasValue) > 1)
// if (allFiller.Map(f => Optional(f.PadToNearestMinute)).Sequence().Flatten().Distinct().Count() > 1)
// multiple pad-to-nearest-minute values are invalid; use no filler
if (allFiller.Count(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue) > 1)
{
// multiple pad-to-nearest-minute values are invalid; use no filler
// TODO: log error?
_logger.LogError("Multiple pad-to-nearest-minute values are invalid; no filler will be used");
return new List<PlayoutItem> { playoutItem };
}
@@ -386,15 +384,18 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
break;
case FillerMode.Count when filler.Count.HasValue:
IMediaCollectionEnumerator e2 = enumerators[CollectionKey.ForFillerPreset(filler)];
for (var i = 0; i < effectiveChapters.Count - 1; i++)
for (var i = 0; i < effectiveChapters.Count; i++)
{
result.Add(playoutItem.ForChapter(effectiveChapters[i]));
result.AddRange(
AddCountFiller(
playoutBuilderState,
e2,
filler.Count.Value,
FillerKind.MidRoll));
if (i < effectiveChapters.Count - 1)
{
result.AddRange(
AddCountFiller(
playoutBuilderState,
e2,
filler.Count.Value,
FillerKind.MidRoll));
}
}
break;
@@ -421,13 +422,14 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
}
// after all non-padded filler has been added, figure out padding
foreach (FillerPreset padFiller in Optional(allFiller.FirstOrDefault(f => f.PadToNearestMinute.HasValue)))
foreach (FillerPreset padFiller in Optional(
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
{
var totalDuration =
TimeSpan.FromMilliseconds(
result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds) +
effectiveChapters.Sum(c => (c.EndTime - c.StartTime).TotalMilliseconds));
int currentMinute = (playoutItem.StartOffset + totalDuration).Minute;
// ReSharper disable once PossibleInvalidOperationException
int targetMinute = (currentMinute + padFiller.PadToNearestMinute.Value - 1) /
@@ -437,7 +439,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
TimeSpan.FromMinutes(currentMinute) +
TimeSpan.FromMinutes(targetMinute);
var targetTime = new DateTimeOffset(
almostTargetTime.Year,
almostTargetTime.Month,
@@ -532,7 +534,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
}
}
}
break;
case FillerKind.PostRoll:
IMediaCollectionEnumerator post1 = enumerators[CollectionKey.ForFillerPreset(padFiller)];

View File

@@ -15,15 +15,18 @@ public static class AvailableEncoders
FrameState currentState,
FrameState desiredState,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile,
ILogger logger) =>
(ffmpegState.HardwareAccelerationMode, desiredState.VideoFormat) switch
{
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc) => new EncoderHevcNvenc(
currentState,
maybeWatermarkInputFile),
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Nvenc, VideoFormat.H264) => new EncoderH264Nvenc(
currentState,
maybeWatermarkInputFile),
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Qsv, VideoFormat.Hevc) => new EncoderHevcQsv(
currentState,
@@ -32,10 +35,12 @@ public static class AvailableEncoders
(HardwareAccelerationMode.Vaapi, VideoFormat.Hevc) => new EncoderHevcVaapi(
currentState,
maybeWatermarkInputFile),
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Vaapi, VideoFormat.H264) => new EncoderH264Vaapi(
currentState,
maybeWatermarkInputFile),
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.VideoToolbox, VideoFormat.Hevc) => new EncoderHevcVideoToolbox(),
(HardwareAccelerationMode.VideoToolbox, VideoFormat.H264) => new EncoderH264VideoToolbox(),

View File

@@ -6,11 +6,16 @@ public class EncoderH264Nvenc : EncoderBase
{
private readonly FrameState _currentState;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
public EncoderH264Nvenc(FrameState currentState, Option<WatermarkInputFile> maybeWatermarkInputFile)
public EncoderH264Nvenc(
FrameState currentState,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile)
{
_currentState = currentState;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
}
public override FrameState NextState(FrameState currentState) => currentState with
@@ -27,10 +32,15 @@ public class EncoderH264Nvenc : EncoderBase
{
get
{
// only upload to hw if we need to overlay a watermark
if (_maybeWatermarkInputFile.IsSome && _currentState.FrameDataLocation == FrameDataLocation.Software)
// only upload to hw if we need to overlay (watermark or subtitle)
if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{
return "hwupload_cuda";
bool isPictureSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false);
if (isPictureSubtitle || _maybeWatermarkInputFile.IsSome)
{
return "hwupload_cuda";
}
}
return string.Empty;

View File

@@ -6,11 +6,16 @@ public class EncoderHevcNvenc : EncoderBase
{
private readonly FrameState _currentState;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
public EncoderHevcNvenc(FrameState currentState, Option<WatermarkInputFile> maybeWatermarkInputFile)
public EncoderHevcNvenc(
FrameState currentState,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile)
{
_currentState = currentState;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
}
public override FrameState NextState(FrameState currentState) => currentState with
@@ -27,10 +32,15 @@ public class EncoderHevcNvenc : EncoderBase
{
get
{
// only upload to hw if we need to overlay a watermark
if (_maybeWatermarkInputFile.IsSome && _currentState.FrameDataLocation == FrameDataLocation.Software)
// only upload to hw if we need to overlay (watermark or subtitle)
if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{
return "hwupload_cuda";
bool isPictureSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false);
if (isPictureSubtitle || _maybeWatermarkInputFile.IsSome)
{
return "hwupload_cuda";
}
}
return string.Empty;

View File

@@ -6,11 +6,16 @@ public class EncoderH264Vaapi : EncoderBase
{
private readonly FrameState _currentState;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
public EncoderH264Vaapi(FrameState currentState, Option<WatermarkInputFile> maybeWatermarkInputFile)
public EncoderH264Vaapi(
FrameState currentState,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile)
{
_currentState = currentState;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
}
public override FrameState NextState(FrameState currentState) => currentState with
@@ -22,14 +27,19 @@ public class EncoderH264Vaapi : EncoderBase
public override string Name => "h264_vaapi";
public override StreamKind Kind => StreamKind.Video;
// need to upload if we're still in software unless a watermark is used
// need to upload if we're still in software unless a watermark or picture subtitle is used
public override string Filter
{
get
{
if (_maybeWatermarkInputFile.IsNone && _currentState.FrameDataLocation == FrameDataLocation.Software)
if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{
return "format=nv12|vaapi,hwupload";
bool isNotImageSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false) == false;
if (_maybeWatermarkInputFile.IsNone && isNotImageSubtitle)
{
return "format=nv12|vaapi,hwupload";
}
}
return string.Empty;

View File

@@ -6,11 +6,16 @@ public class EncoderHevcVaapi : EncoderBase
{
private readonly FrameState _currentState;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
public EncoderHevcVaapi(FrameState currentState, Option<WatermarkInputFile> maybeWatermarkInputFile)
public EncoderHevcVaapi(
FrameState currentState,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile)
{
_currentState = currentState;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
}
public override FrameState NextState(FrameState currentState) => currentState with
@@ -22,14 +27,19 @@ public class EncoderHevcVaapi : EncoderBase
public override string Name => "hevc_vaapi";
public override StreamKind Kind => StreamKind.Video;
// need to upload if we're still in software unless a watermark is used
// need to upload if we're still in software unless a watermark or picture subtitle is used
public override string Filter
{
get
{
if (_maybeWatermarkInputFile.IsNone && _currentState.FrameDataLocation == FrameDataLocation.Software)
if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{
return "format=nv12|vaapi,hwupload";
bool isNotImageSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false) == false;
if (_maybeWatermarkInputFile.IsNone && isNotImageSubtitle)
{
return "format=nv12|vaapi,hwupload";
}
}
return string.Empty;

View File

@@ -1,4 +1,5 @@
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Filter;
@@ -144,8 +145,13 @@ public class ComplexFilter : IPipelineStep
: videoLabel;
// vaapi uses software overlay and needs to upload
// videotoolbox seems to require a hwupload for hevc
// also wait to upload videotoolbox if a subtitle overlay is coming
string uploadFilter = string.Empty;
if (_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
if (_maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false) == false &&
(_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi ||
_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
_currentState.VideoFormat == VideoFormat.Hevc))
{
uploadFilter = new HardwareUploadFilter(_ffmpegState).Filter;
}
@@ -178,6 +184,10 @@ public class ComplexFilter : IPipelineStep
subtitleLabel = "[st]";
subtitleFilterComplex += subtitleLabel;
}
else
{
subtitleLabel = $"[{subtitleLabel}]";
}
IPipelineFilterStep overlayFilter =
AvailableSubtitleOverlayFilters.ForAcceleration(_ffmpegState.HardwareAccelerationMode);
@@ -190,8 +200,11 @@ public class ComplexFilter : IPipelineStep
: videoLabel;
// vaapi uses software overlay and needs to upload
// videotoolbox seems to require a hwupload for hevc
string uploadFilter = string.Empty;
if (_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
if (_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi
|| _ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
_currentState.VideoFormat == VideoFormat.Hevc)
{
uploadFilter = new HardwareUploadFilter(_ffmpegState).Filter;
}

View File

@@ -474,6 +474,7 @@ public class PipelineBuilder
currentState,
desiredState,
_watermarkInputFile,
_subtitleInputFile,
_logger))
{
encoder = e;

View File

@@ -1,4 +1,5 @@
using CliWrap;
using System.Text;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core.Health;
@@ -37,7 +38,7 @@ public abstract class BaseHealthCheck
BufferedCommandResult result = await Cli.Wrap(path)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
return result.StandardOutput;
}

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Extensions;
using MediatR;
@@ -43,11 +44,27 @@ public class InternalController : ControllerBase
result.Match<IActionResult>(
processModel =>
{
Process process = processModel.Process;
Command command = processModel.Process;
_logger.LogInformation("ffmpeg arguments {FFmpegArguments}", command.Arguments);
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command.TargetFilePath,
Arguments = command.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true
}
};
foreach ((string key, string value) in command.EnvironmentVariables)
{
process.StartInfo.Environment[key] = value;
}
_logger.LogInformation(
"ffmpeg arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Images;
using ErsatzTV.Application.Streaming;
@@ -92,15 +93,30 @@ public class IptvController : ControllerBase
result => result.Match<IActionResult>(
processModel =>
{
Process process = processModel.Process;
Command command = processModel.Process;
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
_logger.LogDebug(
"ffmpeg ts arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
_logger.LogInformation("ffmpeg arguments {FFmpegArguments}", command.Arguments);
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command.TargetFilePath,
Arguments = command.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true
}
};
foreach ((string key, string value) in command.EnvironmentVariables)
{
process.StartInfo.Environment[key] = value;
}
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t"); },
error => BadRequest(error.Value)));
}

View File

@@ -74,6 +74,17 @@
{
<MudText>Nothing to see here...</MudText>
}
else
{
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.DeleteForever"
OnClick="@(_ => EmptyTrash())">
Empty Trash
</MudButton>
</div>
}
}
</div>
</MudPaper>
@@ -560,4 +571,21 @@
}
}
private async Task EmptyTrash()
{
int count = _movies.Count + _shows.Count + _seasons.Count + _episodes.Count + _artists.Count +
_musicVideos.Count + _otherVideos.Count + _songs.Count;
var parameters = new DialogParameters { { "EntityType", count.ToString() }, { "EntityName", "missing items" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<DeleteFromDatabaseDialog>("Delete From Database", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
await Mediator.Send(new EmptyTrash(), CancellationToken);
await RefreshData();
}
}
}

View File

@@ -15,6 +15,7 @@ public class FillerPresetEditViewModel
private FillerMode _fillerMode;
private int? _count;
private ProgramScheduleItemCollectionType _collectionType;
private int? _padToNearestMinute;
public int Id { get; set; }
public string Name { get; set; }
@@ -50,7 +51,11 @@ public class FillerPresetEditViewModel
set => _count = value;
}
public int? PadToNearestMinute { get; set; }
public int? PadToNearestMinute
{
get => FillerMode == FillerMode.Pad ? _padToNearestMinute : null;
set => _padToNearestMinute = value;
}
public ProgramScheduleItemCollectionType CollectionType
{