diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 9c03e6e87..015dabba1 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -36,6 +36,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< private readonly ILogger _logger; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator; + private readonly IWatermarkSelector _watermarkSelector; private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly ISongVideoGenerator _songVideoGenerator; private readonly ITelevisionRepository _televisionRepository; @@ -53,6 +54,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< IArtistRepository artistRepository, ISongVideoGenerator songVideoGenerator, IMusicVideoCreditsGenerator musicVideoCreditsGenerator, + IWatermarkSelector watermarkSelector, ILogger logger) : base(dbContextFactory) { @@ -67,6 +69,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< _artistRepository = artistRepository; _songVideoGenerator = songVideoGenerator; _musicVideoCreditsGenerator = musicVideoCreditsGenerator; + _watermarkSelector = watermarkSelector; _logger = logger; } @@ -250,32 +253,17 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< .BindT(watermarkId => dbContext.ChannelWatermarks .SelectOneAsync(w => w.Id, w => w.Id == watermarkId)); - List playoutItemWatermarks = []; - playoutItemWatermarks.AddRange(playoutItemWithPath.PlayoutItem.Watermarks); - - bool disableWatermarks = playoutItemWithPath.PlayoutItem.DisableWatermarks; - WatermarkResult watermarkResult = GetPlayoutItemWatermark(playoutItemWithPath.PlayoutItem, now); - switch (watermarkResult) - { - case InheritWatermark: - // do nothing, other code will fall back to channel/global - break; - case DisableWatermark: - disableWatermarks = true; - break; - case CustomWatermarks watermarks: - playoutItemWatermarks.Clear(); - playoutItemWatermarks.AddRange(watermarks.Watermarks); - break; - } + List watermarks = _watermarkSelector.SelectWatermarks( + maybeGlobalWatermark, + channel, + playoutItemWithPath.PlayoutItem, + now); if (playoutItemWithPath.PlayoutItem.MediaItem is Song song) { (videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo( song, channel, - playoutItemWatermarks.HeadOrNone(), - maybeGlobalWatermark, ffmpegPath, ffprobePath, cancellationToken); @@ -288,21 +276,26 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< bool is43 = Math.Abs(ratio - 4.0 / 3.0) < 0.01; string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png"; - disableWatermarks = false; - playoutItemWatermarks.Clear(); - playoutItemWatermarks.Add( - new ChannelWatermark - { - Mode = ChannelWatermarkMode.Permanent, - Size = WatermarkSize.Scaled, - WidthPercent = 100, - HorizontalMarginPercent = 0, - VerticalMarginPercent = 0, - Opacity = 100, - Location = WatermarkLocation.TopLeft, - ImageSource = ChannelWatermarkImageSource.Resource, - Image = image - }); + var progressWatermark = new ChannelWatermark + { + Mode = ChannelWatermarkMode.Permanent, + Size = WatermarkSize.Scaled, + WidthPercent = 100, + HorizontalMarginPercent = 0, + VerticalMarginPercent = 0, + Opacity = 100, + Location = WatermarkLocation.TopLeft, + ImageSource = ChannelWatermarkImageSource.Resource, + Image = image + }; + + var progressWatermarkOption = new WatermarkOptions( + progressWatermark, + Path.Combine(FileSystemLayout.ResourcesCacheFolder, progressWatermark.Image), + Option.None); + + watermarks.Clear(); + watermarks.Add(progressWatermarkOption); } } @@ -348,8 +341,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< start, finish, effectiveNow, - playoutItemWatermarks, - maybeGlobalWatermark, + watermarks, playoutItemWithPath.PlayoutItem.PlayoutItemGraphicsElements, channel.FFmpegProfile.VaapiDisplay, channel.FFmpegProfile.VaapiDriver, @@ -365,7 +357,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< request.ChannelStartTime, request.PtsOffset, request.TargetFramerate, - disableWatermarks, Option.None, _ => { }); @@ -754,68 +745,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< }; } - private WatermarkResult GetPlayoutItemWatermark(PlayoutItem playoutItem, DateTimeOffset now) - { - if (playoutItem.DisableWatermarks) - { - _logger.LogDebug("Watermark is disabled by playout item"); - return new DisableWatermark(); - } - - DecoEntries decoEntries = GetDecoEntries(playoutItem.Playout, now); - - // first, check deco template / active deco - foreach (Deco templateDeco in decoEntries.TemplateDeco) - { - switch (templateDeco.WatermarkMode) - { - case DecoMode.Override: - if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller) - { - _logger.LogDebug("Watermark will come from template deco (override)"); - return new CustomWatermarks(templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark).ToList()); - } - - _logger.LogDebug("Watermark is disabled by template deco during filler"); - return new DisableWatermark(); - case DecoMode.Disable: - _logger.LogDebug("Watermark is disabled by template deco"); - return new DisableWatermark(); - case DecoMode.Inherit: - _logger.LogDebug("Watermark will inherit from playout deco"); - break; - } - } - - // second, check playout deco - foreach (Deco playoutDeco in decoEntries.PlayoutDeco) - { - switch (playoutDeco.WatermarkMode) - { - case DecoMode.Override: - if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller) - { - _logger.LogDebug("Watermark will come from playout deco (override)"); - return new CustomWatermarks(playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark).ToList()); - } - - _logger.LogDebug("Watermark is disabled by playout deco during filler"); - return new DisableWatermark(); - case DecoMode.Disable: - _logger.LogDebug("Watermark is disabled by playout deco"); - return new DisableWatermark(); - case DecoMode.Inherit: - _logger.LogDebug("Watermark will inherit from channel and/or global setting"); - break; - } - } - - return new InheritWatermark(); - } - private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now) { - DecoEntries decoEntries = GetDecoEntries(playout, now); + DecoEntries decoEntries = DecoSelector.GetDecoEntries(playout, now); // first, check deco template / active deco foreach (Deco templateDeco in decoEntries.TemplateDeco) @@ -864,43 +796,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< return new InheritDeadAirFallback(); } - private static DecoEntries GetDecoEntries(Playout playout, DateTimeOffset now) - { - if (playout is null) - { - return new DecoEntries(Option.None, Option.None); - } - - Option maybePlayoutDeco = Optional(playout.Deco); - Option maybeTemplateDeco = Option.None; - - Option maybeActiveTemplate = - PlayoutTemplateSelector.GetPlayoutTemplateFor(playout.Templates, now); - - foreach (PlayoutTemplate activeTemplate in maybeActiveTemplate) - { - Option maybeItem = Optional(activeTemplate.DecoTemplate) - .SelectMany(dt => dt.Items) - .Find(i => i.StartTime <= now.TimeOfDay && i.EndTime == TimeSpan.Zero || i.EndTime > now.TimeOfDay); - foreach (DecoTemplateItem item in maybeItem) - { - maybeTemplateDeco = Optional(item.Deco); - } - } - - return new DecoEntries(maybeTemplateDeco, maybePlayoutDeco); - } - - private sealed record DecoEntries(Option TemplateDeco, Option PlayoutDeco); - - private abstract record WatermarkResult; - - private sealed record InheritWatermark : WatermarkResult; - - private sealed record DisableWatermark : WatermarkResult; - - private sealed record CustomWatermarks(List Watermarks) : WatermarkResult; - private abstract record DeadAirFallbackResult; private sealed record InheritDeadAirFallback : DeadAirFallbackResult; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 2f1b0a7d2..6ef32d3ec 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -29,6 +29,7 @@ public class PrepareTroubleshootingPlaybackHandler( IFFmpegProcessService ffmpegProcessService, ILocalFileSystem localFileSystem, ISongVideoGenerator songVideoGenerator, + IWatermarkSelector watermarkSelector, IEntityLocker entityLocker, IMediator mediator, ILogger logger) @@ -95,16 +96,22 @@ public class PrepareTroubleshootingPlaybackHandler( StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting, SubtitleMode = SUBTITLE_MODE + //SongVideoMode = ChannelSongVideoMode.WithProgress }; - List watermarks = []; + List watermarks = []; if (request.WatermarkIds.Count > 0) { List channelWatermarks = await dbContext.ChannelWatermarks + .AsNoTracking() .Where(w => request.WatermarkIds.Contains(w.Id)) .ToListAsync(); - watermarks.AddRange(channelWatermarks); + foreach (var watermark in channelWatermarks) + { + watermarks.AddRange( + watermarkSelector.GetWatermarkOptions(channel, watermark, Option.None)); + } } string videoPath = mediaPath; @@ -115,8 +122,6 @@ public class PrepareTroubleshootingPlaybackHandler( (videoPath, videoVersion) = await songVideoGenerator.GenerateSongVideo( song, channel, - Option.None, - Option.None, ffmpegPath, ffprobePath, CancellationToken.None); @@ -129,20 +134,26 @@ public class PrepareTroubleshootingPlaybackHandler( bool is43 = Math.Abs(ratio - 4.0 / 3.0) < 0.01; string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png"; + var progressWatermark = new ChannelWatermark + { + Mode = ChannelWatermarkMode.Permanent, + Size = WatermarkSize.Scaled, + WidthPercent = 100, + HorizontalMarginPercent = 0, + VerticalMarginPercent = 0, + Opacity = 100, + Location = WatermarkLocation.TopLeft, + ImageSource = ChannelWatermarkImageSource.Resource, + Image = image + }; + + var progressWatermarkOption = new WatermarkOptions( + progressWatermark, + Path.Combine(FileSystemLayout.ResourcesCacheFolder, progressWatermark.Image), + Option.None); + watermarks.Clear(); - watermarks.Add( - new ChannelWatermark - { - Mode = ChannelWatermarkMode.Permanent, - Size = WatermarkSize.Scaled, - WidthPercent = 100, - HorizontalMarginPercent = 0, - VerticalMarginPercent = 0, - Opacity = 100, - Location = WatermarkLocation.TopLeft, - ImageSource = ChannelWatermarkImageSource.Resource, - Image = image - }); + watermarks.Add(progressWatermarkOption); } } @@ -200,7 +211,6 @@ public class PrepareTroubleshootingPlaybackHandler( now + duration, now, watermarks, - Option.None, graphicsElements.Map(ge => new PlayoutItemGraphicsElement { GraphicsElement = ge }).ToList(), ffmpegProfile.VaapiDisplay, ffmpegProfile.VaapiDriver, @@ -214,7 +224,6 @@ public class PrepareTroubleshootingPlaybackHandler( channelStartTime: DateTimeOffset.Now, 0, None, - false, FileSystemLayout.TranscodeTroubleshootingFolder, _ => { }); diff --git a/ErsatzTV.Core.Tests/FFmpeg/WatermarkCalculatorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/WatermarkCalculatorTests.cs deleted file mode 100644 index 636a72b47..000000000 --- a/ErsatzTV.Core.Tests/FFmpeg/WatermarkCalculatorTests.cs +++ /dev/null @@ -1,23 +0,0 @@ -using ErsatzTV.Core.FFmpeg; -using NUnit.Framework; -using Shouldly; - -namespace ErsatzTV.Core.Tests.FFmpeg; - -[TestFixture] -public class WatermarkCalculatorTests -{ - [Test] - public void EntireVideoBetweenWatermarks_ShouldReturn_EmptyFadePointList() - { - List actual = WatermarkCalculator.CalculateFadePoints( - new DateTimeOffset(2022, 01, 31, 13, 34, 00, TimeSpan.FromHours(-5)), - TimeSpan.Zero, - TimeSpan.FromMinutes(5), - None, - 15, - 10); - - actual.Count.ShouldBe(0); - } -} diff --git a/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs new file mode 100644 index 000000000..37b5934af --- /dev/null +++ b/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs @@ -0,0 +1,352 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Images; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using Serilog; +using Shouldly; + +namespace ErsatzTV.Core.Tests.FFmpeg; + +[TestFixture] +public class WatermarkSelectorTests +{ + private static readonly WatermarkSelector WatermarkSelector; + private static readonly Option WatermarkNone; + private static readonly ChannelWatermark WatermarkGlobal; + private static readonly ChannelWatermark WatermarkChannel; + private static readonly ChannelWatermark WatermarkPlayoutItem; + private static readonly ChannelWatermark WatermarkTemplateDeco; + private static readonly ChannelWatermark WatermarkDefaultDeco; + private static readonly Channel ChannelWithWatermark; + private static readonly Channel ChannelNoWatermark; + private static readonly DateTimeOffset Now = new(2025, 08, 17, 12, 0, 0, TimeSpan.FromHours(-5)); + + private static readonly PlayoutItem PlayoutItemDisableWatermarks = + new() { Watermarks = [], DisableWatermarks = true }; + + private static readonly PlayoutItem PlayoutItemWithNoWatermarks = + new() { Watermarks = [], DisableWatermarks = false }; + + private static readonly PlayoutItem PlayoutItemWithWatermark; + + private static readonly PlayoutItem PlayoutItemWithDisabledWatermark; + + private static readonly PlayoutItem TemplateDecoInherit; + private static readonly PlayoutItem TemplateDecoDisable; + private static readonly PlayoutItem TemplateDecoInheritWithWatermark; + private static readonly PlayoutItem TemplateDecoDisableWithWatermark; + private static readonly PlayoutItem TemplateDecoOverrideWithWatermark; + private static readonly PlayoutItem TemplateDecoInheritDefaultDecoOverrideWithWatermark; + + private static readonly PlayoutItem DefaultDecoInherit; + private static readonly PlayoutItem DefaultDecoDisable; + private static readonly PlayoutItem DefaultDecoInheritWithWatermark; + private static readonly PlayoutItem DefaultDecoDisableWithWatermark; + private static readonly PlayoutItem DefaultDecoOverrideWithWatermark; + + private static readonly List WatermarkResultEmpty = []; + + static WatermarkSelectorTests() + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .CreateLogger(); + + var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger); + + WatermarkSelector = new WatermarkSelector( + Substitute.For(), + loggerFactory.CreateLogger()); + + WatermarkNone = Option.None; + + WatermarkGlobal = new ChannelWatermark { Id = 0, Name = "Global", Image = "GlobalImage" }; + + WatermarkChannel = new ChannelWatermark { Id = 1, Name = "Channel", Image = "ChannelImage" }; + WatermarkPlayoutItem = new ChannelWatermark { Id = 2, Name = "PlayoutItem", Image = "PlayoutItemImage" }; + WatermarkTemplateDeco = new ChannelWatermark { Id = 3, Name = "TemplateDeco", Image = "TemplateDecoImage" }; + WatermarkDefaultDeco = new ChannelWatermark { Id = 4, Name = "DefaultDeco", Image = "DefaultDecoImage" }; + + ChannelWithWatermark = new Channel(Guid.Empty) + { Id = 0, Watermark = WatermarkChannel, WatermarkId = WatermarkChannel.Id }; + + ChannelNoWatermark = new Channel(Guid.Empty) { Id = 0, Watermark = null, WatermarkId = null }; + + PlayoutItemWithWatermark = new PlayoutItem { Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false }; + + PlayoutItemWithDisabledWatermark = new PlayoutItem + { Watermarks = [WatermarkPlayoutItem], DisableWatermarks = true }; + + var decoWithInherit = new DecoTemplate + { + Items = [new DecoTemplateItem { Deco = new Deco { WatermarkMode = DecoMode.Inherit } }] + }; + + var decoWithDisable = new DecoTemplate + { + Items = [new DecoTemplateItem { Deco = new Deco { WatermarkMode = DecoMode.Disable } }] + }; + + var decoWithOverride = new DecoTemplate + { + Items = + [ + new DecoTemplateItem + { + Deco = new Deco + { + WatermarkMode = DecoMode.Override, + DecoWatermarks = [new DecoWatermark { Watermark = WatermarkTemplateDeco }] + } + } + ] + }; + + var playoutWithTemplateDecoInherit = new Playout + { + Templates = + [ + new PlayoutTemplate + { + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(), + DecoTemplate = decoWithInherit + } + ] + }; + + var playoutWithTemplateDecoDisable = new Playout + { + Templates = + [ + new PlayoutTemplate + { + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(), + DecoTemplate = decoWithDisable + } + ] + }; + + var playoutWithTemplateDecoOverride = new Playout + { + Templates = + [ + new PlayoutTemplate + { + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(), + DecoTemplate = decoWithOverride + } + ] + }; + + TemplateDecoInherit = new PlayoutItem { Playout = playoutWithTemplateDecoInherit, Watermarks = [] }; + + TemplateDecoDisable = new PlayoutItem + { Watermarks = [], DisableWatermarks = false, Playout = playoutWithTemplateDecoDisable }; + + TemplateDecoInheritWithWatermark = new PlayoutItem + { + Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithTemplateDecoInherit + }; + + TemplateDecoDisableWithWatermark = new PlayoutItem + { + Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithTemplateDecoDisable + }; + + TemplateDecoOverrideWithWatermark = new PlayoutItem + { + Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithTemplateDecoOverride + }; + + var playoutWithDecoInherit = new Playout + { + Deco = new Deco { WatermarkMode = DecoMode.Inherit }, + Templates = [] + }; + + var playoutWithDecoDisable = new Playout + { + Deco = new Deco { WatermarkMode = DecoMode.Disable }, + Templates = [] + }; + + var playoutWithDecoOverride = new Playout + { + Deco = new Deco + { + WatermarkMode = DecoMode.Override, + DecoWatermarks = [new DecoWatermark { Watermark = WatermarkDefaultDeco }] + }, + Templates = [] + }; + + DefaultDecoInherit = new PlayoutItem { Playout = playoutWithDecoInherit, Watermarks = [] }; + + DefaultDecoDisable = new PlayoutItem + { Watermarks = [], DisableWatermarks = false, Playout = playoutWithDecoDisable }; + + DefaultDecoInheritWithWatermark = new PlayoutItem + { + Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithDecoInherit + }; + + DefaultDecoDisableWithWatermark = new PlayoutItem + { + Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithDecoDisable + }; + + DefaultDecoOverrideWithWatermark = new PlayoutItem + { + Watermarks = [WatermarkPlayoutItem], DisableWatermarks = false, Playout = playoutWithDecoOverride + }; + + var playoutWithTemplateDecoInheritDefaultDecoOverride = new Playout + { + Templates = + [ + new PlayoutTemplate + { + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(), + DecoTemplate = decoWithInherit + } + ], + Deco = new Deco + { + WatermarkMode = DecoMode.Override, + DecoWatermarks = [new DecoWatermark { Watermark = WatermarkDefaultDeco }] + }, + }; + + TemplateDecoInheritDefaultDecoOverrideWithWatermark = new PlayoutItem + { + Watermarks = [WatermarkPlayoutItem], + DisableWatermarks = false, + Playout = playoutWithTemplateDecoInheritDefaultDecoOverride + }; + + } + + private static IEnumerable<(Option, Channel, PlayoutItem, List)> + SelectWatermarksTestCases() + { + // STANDARD -------------------------------------------- + + // no watermark when none are configured + yield return (WatermarkNone, ChannelNoWatermark, PlayoutItemWithNoWatermarks, WatermarkResultEmpty); + + // no watermark when global configured but disabled playout item + yield return (WatermarkGlobal, ChannelNoWatermark, PlayoutItemDisableWatermarks, WatermarkResultEmpty); + + // global watermark when global configured + yield return (WatermarkGlobal, ChannelNoWatermark, PlayoutItemWithNoWatermarks, [WatermarkGlobal]); + + // channel watermark when global and channel configured + yield return (WatermarkGlobal, ChannelWithWatermark, PlayoutItemWithNoWatermarks, [WatermarkChannel]); + + // channel watermark when channel configured + yield return (WatermarkNone, ChannelWithWatermark, PlayoutItemWithNoWatermarks, [WatermarkChannel]); + + // playout item when global, channel and playout item configured + yield return (WatermarkGlobal, ChannelWithWatermark, PlayoutItemWithWatermark, [WatermarkPlayoutItem]); + + // playout item when channel and playout item configured + yield return (WatermarkNone, ChannelWithWatermark, PlayoutItemWithWatermark, [WatermarkPlayoutItem]); + + // playout item when playout item configured + yield return (WatermarkNone, ChannelNoWatermark, PlayoutItemWithWatermark, [WatermarkPlayoutItem]); + + // no watermark when channel configured with playout item disabled + yield return (WatermarkNone, ChannelWithWatermark, PlayoutItemDisableWatermarks, WatermarkResultEmpty); + + // no watermark when global and channel configured with playout item disabled + yield return (WatermarkGlobal, ChannelWithWatermark, PlayoutItemDisableWatermarks, WatermarkResultEmpty); + + // no watermark when global, channel and playout item configured with playout item disabled + yield return (WatermarkGlobal, ChannelWithWatermark, PlayoutItemWithDisabledWatermark, WatermarkResultEmpty); + + // PLAYOUT TEMPLATE DECO ------------------------------- + + // no watermark when global, channel and playout item configured with template deco disabled + yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoDisableWithWatermark, WatermarkResultEmpty); + + // no watermark when global, channel configured with template deco disabled + yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoDisable, WatermarkResultEmpty); + + // no watermark when global configured with template deco disabled + yield return (WatermarkGlobal, ChannelNoWatermark, TemplateDecoDisable, WatermarkResultEmpty); + + // playout item when global, channel and playout item configured with template deco inherit + yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoInheritWithWatermark, [WatermarkPlayoutItem]); + + // channel when global, channel configured with template deco inherit + yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoInherit, [WatermarkChannel]); + + // global when global configured with template deco inherit + yield return (WatermarkGlobal, ChannelNoWatermark, TemplateDecoInherit, [WatermarkGlobal]); + + // no watermark when none configured with template deco inherit + yield return (WatermarkNone, ChannelNoWatermark, TemplateDecoInherit, WatermarkResultEmpty); + + // PLAYOUT DEFAULT DECO -------------------------------- + + // no watermark when global, channel and playout item configured with default deco disabled + yield return (WatermarkGlobal, ChannelWithWatermark, DefaultDecoDisableWithWatermark, WatermarkResultEmpty); + + // no watermark when global, channel configured with default deco disabled + yield return (WatermarkGlobal, ChannelWithWatermark, DefaultDecoDisable, WatermarkResultEmpty); + + // no watermark when global configured with default deco disabled + yield return (WatermarkGlobal, ChannelNoWatermark, DefaultDecoDisable, WatermarkResultEmpty); + + // playout item when global, channel and playout item configured with default deco inherit + yield return (WatermarkGlobal, ChannelWithWatermark, DefaultDecoInheritWithWatermark, [WatermarkPlayoutItem]); + + // channel when global, channel configured with default deco inherit + yield return (WatermarkGlobal, ChannelWithWatermark, DefaultDecoInherit, [WatermarkChannel]); + + // global when global configured with default deco inherit + yield return (WatermarkGlobal, ChannelNoWatermark, DefaultDecoInherit, [WatermarkGlobal]); + + // no watermark when none configured with default deco inherit + yield return (WatermarkNone, ChannelNoWatermark, DefaultDecoInherit, WatermarkResultEmpty); + + // PLAYOUT TEMPLATE AND DEFAULT DECO ------------------- + + // default deco when global, channel and playout item configured with default deco override, template deco inherit + yield return (WatermarkGlobal, ChannelWithWatermark, TemplateDecoInheritDefaultDecoOverrideWithWatermark, [WatermarkDefaultDeco]); + } + + [TestCaseSource(nameof(SelectWatermarksTestCases))] + public void Should_Select_Appropriate_Watermark( + (Option globalWatermark, + Channel channel, + PlayoutItem playoutItem, + List expectedWatermarks) td) + { + List watermarks = WatermarkSelector.SelectWatermarks( + td.globalWatermark, + td.channel, + td.playoutItem, + Now); + + watermarks.Count.ShouldBe(td.expectedWatermarks.Count); + for (var i = 0; i < td.expectedWatermarks.Count; i++) + { + watermarks[i].Watermark.ShouldBe(td.expectedWatermarks[i]); + } + } + + // TODO: also decos? +} diff --git a/ErsatzTV.Core/Domain/ChannelWatermark.cs b/ErsatzTV.Core/Domain/ChannelWatermark.cs index 59333b7e7..13dec779d 100644 --- a/ErsatzTV.Core/Domain/ChannelWatermark.cs +++ b/ErsatzTV.Core/Domain/ChannelWatermark.cs @@ -28,6 +28,9 @@ public class ChannelWatermark public List Decos { get; set; } public List DecoWatermarks { get; set; } public int ZIndex { get; set; } + + // for unit testing + public override string ToString() => Name; } public enum ChannelWatermarkMode diff --git a/ErsatzTV.Core/FFmpeg/DecoSelector.cs b/ErsatzTV.Core/FFmpeg/DecoSelector.cs new file mode 100644 index 000000000..c4f1719be --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/DecoSelector.cs @@ -0,0 +1,37 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Scheduling; + +namespace ErsatzTV.Core.FFmpeg; + +public static class DecoSelector +{ + public static DecoEntries GetDecoEntries(Playout playout, DateTimeOffset now) + { + if (playout is null) + { + return new DecoEntries(Option.None, Option.None); + } + + Option maybePlayoutDeco = Optional(playout.Deco); + Option maybeTemplateDeco = Option.None; + + Option maybeActiveTemplate = + PlayoutTemplateSelector.GetPlayoutTemplateFor(playout.Templates, now); + + foreach (PlayoutTemplate activeTemplate in maybeActiveTemplate) + { + Option maybeItem = Optional(activeTemplate.DecoTemplate) + .SelectMany(dt => dt.Items) + .Find(i => i.StartTime <= now.TimeOfDay && i.EndTime == TimeSpan.Zero || i.EndTime > now.TimeOfDay); + foreach (DecoTemplateItem item in maybeItem) + { + maybeTemplateDeco = Optional(item.Deco); + } + } + + return new DecoEntries(maybeTemplateDeco, maybePlayoutDeco); + } +} + +public sealed record DecoEntries(Option TemplateDeco, Option PlayoutDeco); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 91a09663c..2d421d5ea 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -64,8 +64,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService DateTimeOffset start, DateTimeOffset finish, DateTimeOffset now, - List playoutItemWatermarks, - Option globalWatermark, + List watermarks, List graphicsElements, string vaapiDisplay, VaapiDriver vaapiDriver, @@ -79,7 +78,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService DateTimeOffset channelStartTime, long ptsOffset, Option targetFramerate, - bool disableWatermarks, Option customReportsFolder, Action pipelineAction) { @@ -330,50 +328,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService List graphicsElementContexts = []; // use graphics engine for all watermarks - if (!disableWatermarks) - { - var watermarks = new Dictionary(); - - // still need channel and global watermarks - if (playoutItemWatermarks.Count == 0) - { - WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions( - ffprobePath, - channel, - Option.None, - globalWatermark, - videoVersion, - None, - None); - - foreach (ChannelWatermark watermark in options.Watermark) - { - // don't allow duplicates - watermarks.TryAdd(watermark.Id, new WatermarkElementContext(options)); - } - } - - // load all playout item watermarks - foreach (ChannelWatermark playoutItemWatermark in playoutItemWatermarks) - { - WatermarkOptions options = await _ffmpegProcessService.GetWatermarkOptions( - ffprobePath, - channel, - playoutItemWatermark, - globalWatermark, - videoVersion, - None, - None); - - foreach (ChannelWatermark watermark in options.Watermark) - { - // don't allow duplicates - watermarks.TryAdd(watermark.Id, new WatermarkElementContext(options)); - } - } - - graphicsElementContexts.AddRange(watermarks.Values); - } + graphicsElementContexts.AddRange(watermarks.Map(wm => new WatermarkElementContext(wm))); HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind); @@ -1047,8 +1002,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService string ffprobePath, Option subtitleFile, Channel channel, - Option playoutItemWatermark, - Option globalWatermark, MediaVersion videoVersion, string videoPath, bool boxBlur, @@ -1063,8 +1016,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService ffprobePath, subtitleFile, channel, - playoutItemWatermark, - globalWatermark, videoVersion, videoPath, boxBlur, diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs index d901fad39..aac666517 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs @@ -48,8 +48,7 @@ internal class FFmpegProcessBuilder Option> maybeFadePoints, IDisplaySize resolution) { - ChannelWatermarkMode maybeWatermarkMode = watermarkOptions.Map(wmo => wmo.Watermark.Map(wm => wm.Mode)) - .Flatten() + ChannelWatermarkMode maybeWatermarkMode = watermarkOptions.Map(wmo => wmo.Watermark.Mode) .IfNone(ChannelWatermarkMode.None); // skip watermark if intermittent and no fade points @@ -59,30 +58,14 @@ internal class FFmpegProcessBuilder { foreach (WatermarkOptions options in watermarkOptions) { - foreach (string path in options.ImagePath) - { - if (options.IsAnimated) - { - _arguments.Add("-ignore_loop"); - _arguments.Add("0"); - } - - // when we have fade points, we need to loop the static watermark image - else if (maybeFadePoints.Map(fp => fp.Count).IfNone(0) > 0) - { - _arguments.Add("-stream_loop"); - _arguments.Add("-1"); - } - - _arguments.Add("-i"); - _arguments.Add(path); - - _complexFilterBuilder = _complexFilterBuilder.WithWatermark( - options.Watermark, - maybeFadePoints, - resolution, - options.ImageStreamIndex); - } + _arguments.Add("-i"); + _arguments.Add(options.ImagePath); + + _complexFilterBuilder = _complexFilterBuilder.WithWatermark( + options.Watermark, + maybeFadePoints, + resolution, + options.ImageStreamIndex); } } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index a21642113..a85cd3d41 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -1,15 +1,10 @@ using System.Diagnostics; -using System.Text; using Bugsnag; using CliWrap; -using CliWrap.Buffered; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Images; using ErsatzTV.Core.Interfaces.FFmpeg; -using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.FFmpeg; using ErsatzTV.FFmpeg.State; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using MediaStream = ErsatzTV.Core.Domain.MediaStream; @@ -19,24 +14,18 @@ public class FFmpegProcessService { private readonly IClient _client; private readonly IFFmpegStreamSelector _ffmpegStreamSelector; - private readonly IImageCache _imageCache; private readonly ILogger _logger; - private readonly IMemoryCache _memoryCache; private readonly ITempFilePool _tempFilePool; public FFmpegProcessService( IFFmpegStreamSelector ffmpegStreamSelector, - IImageCache imageCache, ITempFilePool tempFilePool, IClient client, - IMemoryCache memoryCache, ILogger logger) { _ffmpegStreamSelector = ffmpegStreamSelector; - _imageCache = imageCache; _tempFilePool = tempFilePool; _client = client; - _memoryCache = memoryCache; _logger = logger; } @@ -45,8 +34,6 @@ public class FFmpegProcessService string ffprobePath, Option subtitleFile, Channel channel, - Option playoutItemWatermark, - Option globalWatermark, MediaVersion videoVersion, string videoPath, bool boxBlur, @@ -63,29 +50,25 @@ public class FFmpegProcessService MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion); - Option watermarkOverride = - videoVersion is FallbackMediaVersion or CoverArtMediaVersion - ? new ChannelWatermark - { - Mode = ChannelWatermarkMode.Permanent, - HorizontalMarginPercent = horizontalMarginPercent, - VerticalMarginPercent = verticalMarginPercent, - Location = watermarkLocation, - Size = WatermarkSize.Scaled, - WidthPercent = watermarkWidthPercent, - Opacity = 100 - } - : None; - - Option watermarkOptions = - await GetWatermarkOptions( - ffprobePath, - channel, - playoutItemWatermark, - globalWatermark, - videoVersion, - watermarkOverride, - watermarkPath); + Option watermarkOptions = Option.None; + if (videoVersion is FallbackMediaVersion or CoverArtMediaVersion) + { + var songWatermark = new ChannelWatermark + { + Mode = ChannelWatermarkMode.Permanent, + HorizontalMarginPercent = horizontalMarginPercent, + VerticalMarginPercent = verticalMarginPercent, + Location = watermarkLocation, + Size = WatermarkSize.Scaled, + WidthPercent = watermarkWidthPercent, + Opacity = 100 + }; + + watermarkOptions = new WatermarkOptions( + songWatermark, + await watermarkPath.IfNoneAsync(videoVersion.MediaFiles.Head().Path), + 0); + } FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateErrorSettings( @@ -156,227 +139,4 @@ public class FFmpegProcessService private static bool NeedToPad(Resolution target, IDisplaySize displaySize) => displaySize.Width != target.Width || displaySize.Height != target.Height; - - internal async Task GetWatermarkOptions( - string ffprobePath, - Channel channel, - Option playoutItemWatermark, - Option globalWatermark, - MediaVersion videoVersion, - Option watermarkOverride, - Option watermarkPath) - { - if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect) - { - if (videoVersion is CoverArtMediaVersion) - { - return new WatermarkOptions( - watermarkOverride, - await watermarkPath.IfNoneAsync(videoVersion.MediaFiles.Head().Path), - 0, - false); - } - - // check for playout item watermark - foreach (ChannelWatermark watermark in playoutItemWatermark) - { - switch (watermark.ImageSource) - { - // used for song progress overlay - case ChannelWatermarkImageSource.Resource: - return new WatermarkOptions( - await watermarkOverride.IfNoneAsync(watermark), - Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image), - Option.None, - false); - case ChannelWatermarkImageSource.Custom: - // bad form validation makes this possible - if (string.IsNullOrWhiteSpace(watermark.Image)) - { - _logger.LogWarning( - "Watermark {Name} has custom image configured with no image; ignoring", - watermark.Name); - break; - } - - _logger.LogDebug("Watermark will come from playout item (custom)"); - - string customPath = _imageCache.GetPathForImage( - watermark.Image, - ArtworkKind.Watermark, - Option.None); - return new WatermarkOptions( - await watermarkOverride.IfNoneAsync(watermark), - customPath, - None, - await IsAnimated(ffprobePath, customPath)); - case ChannelWatermarkImageSource.ChannelLogo: - _logger.LogDebug("Watermark will come from playout item (channel logo)"); - - Option maybeChannelPath = channel.Artwork.Count == 0 - ? - //We have to generate the logo on the fly and save it to a local temp path - ChannelLogoGenerator.GenerateChannelLogoUrl(channel) - : - //We have an artwork attached to the channel, let's use it :) - channel.Artwork - .Filter(a => a.ArtworkKind == ArtworkKind.Logo) - .HeadOrNone() - .Map(a => Artwork.IsExternalUrl(a.Path) - ? a.Path - : _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option.None)); - - return new WatermarkOptions( - await watermarkOverride.IfNoneAsync(watermark), - maybeChannelPath, - None, - await maybeChannelPath.Match( - p => IsAnimated(ffprobePath, p), - () => Task.FromResult(false))); - default: - throw new NotSupportedException("Unsupported watermark image source"); - } - } - - // check for channel watermark - if (channel.Watermark != null) - { - switch (channel.Watermark.ImageSource) - { - case ChannelWatermarkImageSource.Custom: - _logger.LogDebug("Watermark will come from channel (custom)"); - - string customPath = _imageCache.GetPathForImage( - channel.Watermark.Image, - ArtworkKind.Watermark, - Option.None); - return new WatermarkOptions( - await watermarkOverride.IfNoneAsync(channel.Watermark), - customPath, - None, - await IsAnimated(ffprobePath, customPath)); - case ChannelWatermarkImageSource.ChannelLogo: - _logger.LogDebug("Watermark will come from channel (channel logo)"); - - Option maybeChannelPath = channel.Artwork.Count == 0 - ? - //We have to generate the logo on the fly and save it to a local temp path - ChannelLogoGenerator.GenerateChannelLogoUrl(channel) - : - //We have an artwork attached to the channel, let's use it :) - channel.Artwork - .Filter(a => a.ArtworkKind == ArtworkKind.Logo) - .HeadOrNone() - .Map(a => Artwork.IsExternalUrl(a.Path) - ? a.Path - : _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option.None)); - return new WatermarkOptions( - await watermarkOverride.IfNoneAsync(channel.Watermark), - maybeChannelPath, - None, - await maybeChannelPath.Match( - p => IsAnimated(ffprobePath, p), - () => Task.FromResult(false))); - default: - throw new NotSupportedException("Unsupported watermark image source"); - } - } - - // check for global watermark - foreach (ChannelWatermark watermark in globalWatermark) - { - switch (watermark.ImageSource) - { - case ChannelWatermarkImageSource.Custom: - _logger.LogDebug("Watermark will come from global (custom)"); - - string customPath = _imageCache.GetPathForImage( - watermark.Image, - ArtworkKind.Watermark, - Option.None); - return new WatermarkOptions( - await watermarkOverride.IfNoneAsync(watermark), - customPath, - None, - await IsAnimated(ffprobePath, customPath)); - case ChannelWatermarkImageSource.ChannelLogo: - _logger.LogDebug("Watermark will come from global (channel logo)"); - - Option maybeChannelPath = channel.Artwork.Count == 0 - ? - //We have to generate the logo on the fly and save it to a local temp path - ChannelLogoGenerator.GenerateChannelLogoUrl(channel) - : - //We have an artwork attached to the channel, let's use it :) - channel.Artwork - .Filter(a => a.ArtworkKind == ArtworkKind.Logo) - .HeadOrNone() - .Map(a => Artwork.IsExternalUrl(a.Path) - ? a.Path - : _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option.None)); - return new WatermarkOptions( - await watermarkOverride.IfNoneAsync(watermark), - maybeChannelPath, - None, - await maybeChannelPath.Match( - p => IsAnimated(ffprobePath, p), - () => Task.FromResult(false))); - default: - throw new NotSupportedException("Unsupported watermark image source"); - } - } - } - - return new WatermarkOptions(None, None, None, false); - } - - private async Task IsAnimated(string ffprobePath, string path) - { - try - { - var cacheKey = $"image.animated.{Path.GetFileName(path)}"; - if (_memoryCache.TryGetValue(cacheKey, out bool animated)) - { - return animated; - } - - BufferedCommandResult result = await Cli.Wrap(ffprobePath) - .WithArguments( - [ - "-loglevel", "error", - "-select_streams", "v:0", - "-count_frames", - "-show_entries", "stream=nb_read_frames", - "-print_format", "csv", - path - ]) - .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(Encoding.UTF8); - - if (result.ExitCode == 0) - { - string output = result.StandardOutput; - output = output.Replace("stream,", string.Empty); - if (int.TryParse(output, out int frameCount)) - { - bool isAnimated = frameCount > 1; - _memoryCache.Set(cacheKey, isAnimated, TimeSpan.FromDays(1)); - return isAnimated; - } - } - else - { - _logger.LogWarning( - "Error checking frame count for file {File}l exit code {ExitCode}", - path, - result.ExitCode); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error checking frame count for file {File}", path); - } - - return false; - } } diff --git a/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs b/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs index 519c7759c..95f4ba993 100644 --- a/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs +++ b/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs @@ -10,7 +10,7 @@ namespace ErsatzTV.Core.FFmpeg; public class SongVideoGenerator : ISongVideoGenerator { private static readonly Random Random = new(); - private static readonly object RandomLock = new(); + private static readonly Lock RandomLock = new(); private readonly IFFmpegProcessService _ffmpegProcessService; private readonly IImageCache _imageCache; @@ -29,8 +29,6 @@ public class SongVideoGenerator : ISongVideoGenerator public async Task> GenerateSongVideo( Song song, Channel channel, - Option maybePlayoutItemWatermark, - Option maybeGlobalWatermark, string ffmpegPath, string ffprobePath, CancellationToken cancellationToken) @@ -219,18 +217,13 @@ public class SongVideoGenerator : ISongVideoGenerator string videoPath = backgroundPath; - videoVersion.MediaFiles = new List - { - new() { Path = videoPath } - }; + videoVersion.MediaFiles = [new MediaFile { Path = videoPath }]; Either maybeSongImage = await _ffmpegProcessService.GenerateSongImage( ffmpegPath, ffprobePath, subtitleFile, channel, - maybePlayoutItemWatermark, - maybeGlobalWatermark, videoVersion, videoPath, boxBlur, diff --git a/ErsatzTV.Core/FFmpeg/WatermarkCalculator.cs b/ErsatzTV.Core/FFmpeg/WatermarkCalculator.cs deleted file mode 100644 index 973d40231..000000000 --- a/ErsatzTV.Core/FFmpeg/WatermarkCalculator.cs +++ /dev/null @@ -1,79 +0,0 @@ -namespace ErsatzTV.Core.FFmpeg; - -public static class WatermarkCalculator -{ - public static List CalculateFadePoints( - DateTimeOffset itemStartTime, - TimeSpan inPoint, - TimeSpan outPoint, - Option streamSeek, - int frequencyMinutes, - int durationSeconds) - { - var result = new List(); - - TimeSpan duration = outPoint - inPoint; - DateTimeOffset itemFinishTime = itemStartTime + duration; - - DateTimeOffset start = itemStartTime.AddMinutes(-16); - - // find the next whole minute - if (start.Second > 0 || start.Millisecond > 0) - { - start = start.AddMinutes(1); - start = start.AddSeconds(-start.Second); - start = start.AddMilliseconds(-start.Millisecond); - } - - DateTimeOffset finish = itemFinishTime; - - // find the previous whole minute - if (finish.Second > 0 || finish.Millisecond > 0) - { - finish = finish.AddSeconds(-finish.Second); - finish = finish.AddMilliseconds(-finish.Millisecond); - } - - DateTimeOffset current = start; - while (current <= finish) - { - current = current.AddMinutes(1); - if (current.Minute % frequencyMinutes == 0) - { - TimeSpan fadeInTime = inPoint + (current - itemStartTime); - - result.Add(new FadeInPoint(fadeInTime)); - result.Add(new FadeOutPoint(fadeInTime.Add(TimeSpan.FromSeconds(durationSeconds)))); - } - } - - // if we're seeking, subtract the seek from each item and return that - foreach (TimeSpan ss in streamSeek) - { - result = result.Map(fp => fp with { Time = fp.Time - ss }).ToList(); - } - - // trim points that have already passed - result.RemoveAll(fp => fp.Time < TimeSpan.Zero); - - // trim points that are past the end - result.RemoveAll(fp => fp.Time >= outPoint); - - if (result.Count != 0) - { - for (var i = 0; i < result.Count; i++) - { - result[i].EnableStart = i == 0 ? TimeSpan.Zero : result[i - 1].Time.Add(TimeSpan.FromSeconds(1)); - } - - for (var i = 0; i < result.Count; i++) - { - result[i].EnableFinish = i == result.Count - 1 - ? outPoint - : result[i + 1].Time.Subtract(TimeSpan.FromSeconds(1)); - } - } - - return result; - } -} diff --git a/ErsatzTV.Core/FFmpeg/WatermarkOptions.cs b/ErsatzTV.Core/FFmpeg/WatermarkOptions.cs index 70d40e3fc..253493134 100644 --- a/ErsatzTV.Core/FFmpeg/WatermarkOptions.cs +++ b/ErsatzTV.Core/FFmpeg/WatermarkOptions.cs @@ -3,7 +3,9 @@ namespace ErsatzTV.Core.FFmpeg; public record WatermarkOptions( - Option Watermark, - Option ImagePath, - Option ImageStreamIndex, - bool IsAnimated); + ChannelWatermark Watermark, + string ImagePath, + Option ImageStreamIndex) +{ + public static WatermarkOptions NoWatermark => new(null, null, None); +} diff --git a/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs b/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs new file mode 100644 index 000000000..afffd59ad --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs @@ -0,0 +1,298 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Images; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Images; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Core.FFmpeg; + +public class WatermarkSelector(IImageCache imageCache, ILogger logger) + : IWatermarkSelector +{ + public List SelectWatermarks( + Option globalWatermark, + Channel channel, + PlayoutItem playoutItem, + DateTimeOffset now) + { + var result = new List(); + + if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect) + { + return result; + } + + if (playoutItem.DisableWatermarks) + { + logger.LogDebug("Watermark is disabled by playout item"); + return result; + } + + DecoEntries decoEntries = DecoSelector.GetDecoEntries(playoutItem.Playout, now); + + // first, check deco template / active deco + foreach (Deco templateDeco in decoEntries.TemplateDeco) + { + switch (templateDeco.WatermarkMode) + { + case DecoMode.Override: + if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller) + { + logger.LogDebug("Watermark will come from template deco (override)"); + result.AddRange( + OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); + return result; + } + + logger.LogDebug("Watermark is disabled by template deco during filler"); + return result; + case DecoMode.Disable: + logger.LogDebug("Watermark is disabled by template deco"); + return result; + case DecoMode.Inherit: + logger.LogDebug("Watermark will inherit from playout deco"); + break; + } + } + + // second, check playout deco + foreach (Deco playoutDeco in decoEntries.PlayoutDeco) + { + switch (playoutDeco.WatermarkMode) + { + case DecoMode.Override: + if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller) + { + logger.LogDebug("Watermark will come from playout deco (override)"); + result.AddRange( + OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); + return result; + } + + logger.LogDebug("Watermark is disabled by playout deco during filler"); + return result; + case DecoMode.Disable: + logger.LogDebug("Watermark is disabled by playout deco"); + return result; + case DecoMode.Inherit: + logger.LogDebug("Watermark will inherit from channel and/or global setting"); + break; + } + } + + if (playoutItem.Watermarks.Count > 0) + { + foreach (var watermark in playoutItem.Watermarks) + { + var options = GetWatermarkOptions(channel, watermark, Option.None); + result.AddRange(options); + } + + return result; + } + + + var finalOptions = GetWatermarkOptions(channel, Option.None, globalWatermark); + result.AddRange(finalOptions); + + return result; + } + + public Option GetWatermarkOptions( + Channel channel, + Option playoutItemWatermark, + Option globalWatermark) + { + if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect) + { + return Option.None; + } + + // check for playout item watermark + foreach (ChannelWatermark watermark in playoutItemWatermark) + { + switch (watermark.ImageSource) + { + // used for song progress overlay + case ChannelWatermarkImageSource.Resource: + return new WatermarkOptions( + watermark, + Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image), + Option.None); + case ChannelWatermarkImageSource.Custom: + // bad form validation makes this possible + if (string.IsNullOrWhiteSpace(watermark.Image)) + { + logger.LogWarning( + "Watermark {Name} has custom image configured with no image; ignoring", + watermark.Name); + break; + } + + logger.LogDebug("Watermark will come from playout item (custom)"); + + string customPath = imageCache.GetPathForImage( + watermark.Image, + ArtworkKind.Watermark, + Option.None); + return new WatermarkOptions( + watermark, + customPath, + None); + case ChannelWatermarkImageSource.ChannelLogo: + logger.LogDebug("Watermark will come from playout item (channel logo)"); + + string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); + Option maybeLogoArtwork = + Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo)); + foreach (var logoArtwork in maybeLogoArtwork) + { + channelPath = Artwork.IsExternalUrl(logoArtwork.Path) + ? logoArtwork.Path + : imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option.None); + } + + return new WatermarkOptions(watermark, channelPath, None); + default: + throw new NotSupportedException("Unsupported watermark image source"); + } + } + + // check for channel watermark + if (channel.Watermark != null) + { + switch (channel.Watermark.ImageSource) + { + case ChannelWatermarkImageSource.Custom: + logger.LogDebug("Watermark will come from channel (custom)"); + + string customPath = imageCache.GetPathForImage( + channel.Watermark.Image, + ArtworkKind.Watermark, + Option.None); + return new WatermarkOptions( + channel.Watermark, + customPath, + None); + case ChannelWatermarkImageSource.ChannelLogo: + logger.LogDebug("Watermark will come from channel (channel logo)"); + + string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); + Option maybeLogoArtwork = + Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo)); + foreach (var logoArtwork in maybeLogoArtwork) + { + channelPath = Artwork.IsExternalUrl(logoArtwork.Path) + ? logoArtwork.Path + : imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option.None); + } + + return new WatermarkOptions(channel.Watermark, channelPath, None); + default: + throw new NotSupportedException("Unsupported watermark image source"); + } + } + + // check for global watermark + foreach (ChannelWatermark watermark in globalWatermark) + { + switch (watermark.ImageSource) + { + case ChannelWatermarkImageSource.Custom: + logger.LogDebug("Watermark will come from global (custom)"); + + string customPath = imageCache.GetPathForImage( + watermark.Image, + ArtworkKind.Watermark, + Option.None); + return new WatermarkOptions( + watermark, + customPath, + None); + case ChannelWatermarkImageSource.ChannelLogo: + logger.LogDebug("Watermark will come from global (channel logo)"); + + string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); + Option maybeLogoArtwork = + Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo)); + foreach (var logoArtwork in maybeLogoArtwork) + { + channelPath = Artwork.IsExternalUrl(logoArtwork.Path) + ? logoArtwork.Path + : imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option.None); + } + + return new WatermarkOptions(watermark, channelPath, None); + default: + throw new NotSupportedException("Unsupported watermark image source"); + } + } + + return Option.None; + } + + private List OptionsForWatermarks(Channel channel, IEnumerable watermarks) + { + var result = new List(); + + foreach (var watermark in watermarks) + { + result.AddRange(GetWatermarkOptions(channel, watermark)); + } + + return result; + } + + private Option GetWatermarkOptions(Channel channel, ChannelWatermark watermark) + { + switch (watermark.ImageSource) + { + // used for song progress overlay + case ChannelWatermarkImageSource.Resource: + return new WatermarkOptions( + watermark, + Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image), + Option.None); + case ChannelWatermarkImageSource.Custom: + // bad form validation makes this possible + if (string.IsNullOrWhiteSpace(watermark.Image)) + { + logger.LogWarning( + "Watermark {Name} has custom image configured with no image; ignoring", + watermark.Name); + break; + } + + logger.LogDebug("Watermark will come from playout item (custom)"); + + string customPath = imageCache.GetPathForImage( + watermark.Image, + ArtworkKind.Watermark, + Option.None); + return new WatermarkOptions( + watermark, + customPath, + None); + case ChannelWatermarkImageSource.ChannelLogo: + logger.LogDebug("Watermark will come from playout item (channel logo)"); + + string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); + Option maybeLogoArtwork = + Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo)); + foreach (var logoArtwork in maybeLogoArtwork) + { + channelPath = Artwork.IsExternalUrl(logoArtwork.Path) + ? logoArtwork.Path + : imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option.None); + } + + return new WatermarkOptions(watermark, channelPath, None); + default: + throw new NotSupportedException("Unsupported watermark image source"); + } + + return Option.None; + } +} diff --git a/ErsatzTV.Core/Images/ChannelLogoGenerator.cs b/ErsatzTV.Core/Images/ChannelLogoGenerator.cs index 193995920..4f6504a90 100644 --- a/ErsatzTV.Core/Images/ChannelLogoGenerator.cs +++ b/ErsatzTV.Core/Images/ChannelLogoGenerator.cs @@ -78,6 +78,6 @@ public class ChannelLogoGenerator : IChannelLogoGenerator } } - public static Option GenerateChannelLogoUrl(Channel channel) => + public static string GenerateChannelLogoUrl(Channel channel) => $"http://localhost:{Settings.StreamingPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}"; } diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index b8014c437..1057a8edc 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -26,8 +26,7 @@ public interface IFFmpegProcessService DateTimeOffset start, DateTimeOffset finish, DateTimeOffset now, - List playoutItemWatermarks, - Option globalWatermark, + List watermarks, List graphicsElements, string vaapiDisplay, VaapiDriver vaapiDriver, @@ -41,7 +40,6 @@ public interface IFFmpegProcessService DateTimeOffset channelStartTime, long ptsOffset, Option targetFramerate, - bool disableWatermarks, Option customReportsFolder, Action pipelineAction); @@ -81,8 +79,6 @@ public interface IFFmpegProcessService string ffprobePath, Option subtitleFile, Channel channel, - Option playoutItemWatermark, - Option globalWatermark, MediaVersion videoVersion, string videoPath, bool boxBlur, diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/ISongVideoGenerator.cs b/ErsatzTV.Core/Interfaces/FFmpeg/ISongVideoGenerator.cs index b1d82985d..d4b1f9050 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/ISongVideoGenerator.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/ISongVideoGenerator.cs @@ -7,8 +7,6 @@ public interface ISongVideoGenerator Task> GenerateSongVideo( Song song, Channel channel, - Option maybePlayoutItemWatermark, - Option maybeGlobalWatermark, string ffmpegPath, string ffprobePath, CancellationToken cancellationToken); diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs new file mode 100644 index 000000000..c2c9ae1f0 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs @@ -0,0 +1,18 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; + +namespace ErsatzTV.Core.Interfaces.FFmpeg; + +public interface IWatermarkSelector +{ + List SelectWatermarks( + Option globalWatermark, + Channel channel, + PlayoutItem playoutItem, + DateTimeOffset now); + + Option GetWatermarkOptions( + Channel channel, + Option playoutItemWatermark, + Option globalWatermark); +} diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/WatermarkResult.cs b/ErsatzTV.Core/Interfaces/FFmpeg/WatermarkResult.cs new file mode 100644 index 000000000..c5139ef88 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/FFmpeg/WatermarkResult.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.FFmpeg; + +public abstract record WatermarkResult; + +public sealed record InheritWatermark : WatermarkResult; + +public sealed record DisableWatermark : WatermarkResult; + +public sealed record CustomWatermarks(List Watermarks) : WatermarkResult; diff --git a/ErsatzTV.FFmpeg/Filter/OverlayGraphicsEngineFilter.cs b/ErsatzTV.FFmpeg/Filter/OverlayGraphicsEngineFilter.cs index 7e12ee20f..e6e9cfb1c 100644 --- a/ErsatzTV.FFmpeg/Filter/OverlayGraphicsEngineFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/OverlayGraphicsEngineFilter.cs @@ -5,7 +5,7 @@ namespace ErsatzTV.FFmpeg.Filter; public class OverlayGraphicsEngineFilter(IPixelFormat outputPixelFormat) : BaseFilter { public override string Filter => - $"overlay=format={(outputPixelFormat.BitDepth == 10 ? '1' : '0')}:alpha=premultiplied"; + $"overlay=format={(outputPixelFormat.BitDepth == 10 ? '1' : '0')}"; public override FrameState NextState(FrameState currentState) => currentState with { FrameDataLocation = FrameDataLocation.Software }; diff --git a/ErsatzTV.FFmpeg/Filter/Vaapi/OverlayGraphicsEngineVaapiFilter.cs b/ErsatzTV.FFmpeg/Filter/Vaapi/OverlayGraphicsEngineVaapiFilter.cs index ba386e2ec..cd018db0e 100644 --- a/ErsatzTV.FFmpeg/Filter/Vaapi/OverlayGraphicsEngineVaapiFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/Vaapi/OverlayGraphicsEngineVaapiFilter.cs @@ -7,7 +7,7 @@ public class OverlayGraphicsEngineVaapiFilter(FrameState currentState, IPixelFor public override string Filter => currentState.FrameDataLocation is FrameDataLocation.Hardware ? "overlay_vaapi" - : $"overlay=format={(outputPixelFormat.BitDepth == 10 ? '1' : '0')}:alpha=premultiplied"; + : $"overlay=format={(outputPixelFormat.BitDepth == 10 ? '1' : '0')}"; public override FrameState NextState(FrameState currentState) => currentState; } diff --git a/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs index 7ea99c9fc..2549ec9a9 100644 --- a/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs @@ -655,9 +655,6 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder { graphicsEngine.FilterSteps.Add(new PixelFormatFilter(new PixelFormatYuva420P())); - // overlay_cuda expects straight alpha - graphicsEngine.FilterSteps.Add(new UnpremultiplyFilter()); - graphicsEngine.FilterSteps.Add( new HardwareUploadCudaFilter(currentState with { FrameDataLocation = FrameDataLocation.Software })); diff --git a/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs index f592c6f55..d9417fe6e 100644 --- a/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs @@ -557,9 +557,6 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder if (currentState.FrameDataLocation is FrameDataLocation.Hardware) { - // overlay_vaapi expects straight alpha - graphicsEngine.FilterSteps.Add(new UnpremultiplyFilter()); - graphicsEngine.FilterSteps.Add(new HardwareUploadVaapiFilter(false)); } diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs index 12c450ee8..7e65d237e 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs @@ -129,7 +129,7 @@ public class GraphicsEngine( context.FrameSize.Width, context.FrameSize.Height, SKColorType.Bgra8888, - SKAlphaType.Premul); + SKAlphaType.Unpremul); try { diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs index 8816e66f5..1ca662f3a 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs @@ -20,16 +20,10 @@ public class WatermarkElement : ImageElementBase { _logger = logger; // TODO: better model coming in here? - foreach (string imagePath in watermarkOptions.ImagePath) - { - _imagePath = imagePath; - } - foreach (ChannelWatermark watermark in watermarkOptions.Watermark) - { - _watermark = watermark; - ZIndex = watermark.ZIndex; - } + _imagePath = watermarkOptions.ImagePath; + _watermark = watermarkOptions.Watermark; + ZIndex = watermarkOptions.Watermark.ZIndex; } public bool IsValid => _imagePath != null && _watermark != null; diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index e14e31d7e..6e6c764f2 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -254,10 +254,8 @@ public class TranscodingTests var oldService = new FFmpegProcessService( new FakeStreamSelector(), - mockImageCache, tempFilePool, Substitute.For(), - MemoryCache, LoggerFactory.CreateLogger()); var service = new FFmpegLibraryProcessService( @@ -317,8 +315,6 @@ public class TranscodingTests (string videoPath, MediaVersion videoVersion) = await songVideoGenerator.GenerateSongVideo( song, channel, - None, // playout item watermark - None, // global watermark ExecutableName("ffmpeg"), ExecutableName("ffprobe"), CancellationToken.None); @@ -350,6 +346,16 @@ public class TranscodingTests DateTimeOffset now = DateTimeOffset.Now; + WatermarkSelector watermarkSelector = new WatermarkSelector( + mockImageCache, + LoggerFactory.CreateLogger()); + + List watermarks = []; + foreach (var wm in GetWatermark(watermark)) + { + watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option.None)); + } + PlayoutItemResult playoutItemResult = await service.ForPlayoutItem( ExecutableName("ffmpeg"), ExecutableName("ffprobe"), @@ -367,8 +373,7 @@ public class TranscodingTests now, now + TimeSpan.FromSeconds(3), now, - [], - GetWatermark(watermark), + watermarks, [], "drm", VaapiDriver.RadeonSI, @@ -382,7 +387,6 @@ public class TranscodingTests DateTimeOffset.Now, 0, None, - false, Option.None, _ => { }); @@ -616,25 +620,51 @@ public class TranscodingTests FFmpegLibraryProcessService service = GetService(); + var channel = new Channel(Guid.NewGuid()) + { + Number = "1", + FFmpegProfile = FFmpegProfile.New("test", profileResolution) with + { + HardwareAcceleration = profileAcceleration, + VideoFormat = profileVideoFormat, + AudioFormat = FFmpegProfileAudioFormat.Aac, + DeinterlaceVideo = true, + BitDepth = profileBitDepth, + ScalingBehavior = scalingBehavior + }, + StreamingMode = streamingMode, + SubtitleMode = subtitleMode + }; + + var localFileSystem = new LocalFileSystem( + Substitute.For(), + LoggerFactory.CreateLogger()); + var tempFilePool = new TempFilePool(); + + ImageCache mockImageCache = Substitute.For(localFileSystem, tempFilePool); + + // always return the static watermark resource + mockImageCache.GetPathForImage( + Arg.Any(), + Arg.Is(x => x == ArtworkKind.Watermark), + Arg.Any>()) + .Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png")); + + WatermarkSelector watermarkSelector = new WatermarkSelector( + mockImageCache, + LoggerFactory.CreateLogger()); + + List watermarks = []; + foreach (var wm in channelWatermark) + { + watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option.None)); + } + PlayoutItemResult playoutItemResult = await service.ForPlayoutItem( ExecutableName("ffmpeg"), ExecutableName("ffprobe"), false, - new Channel(Guid.NewGuid()) - { - Number = "1", - FFmpegProfile = FFmpegProfile.New("test", profileResolution) with - { - HardwareAcceleration = profileAcceleration, - VideoFormat = profileVideoFormat, - AudioFormat = FFmpegProfileAudioFormat.Aac, - DeinterlaceVideo = true, - BitDepth = profileBitDepth, - ScalingBehavior = scalingBehavior - }, - StreamingMode = streamingMode, - SubtitleMode = subtitleMode - }, + channel, v, new MediaItemAudioVersion(null, v), file, @@ -647,8 +677,7 @@ public class TranscodingTests now, now + TimeSpan.FromSeconds(3), now, - [], - channelWatermark, + watermarks, [], "drm", VaapiDriver.RadeonSI, @@ -662,7 +691,6 @@ public class TranscodingTests DateTimeOffset.Now, 0, None, - false, Option.None, PipelineAction); @@ -898,10 +926,8 @@ public class TranscodingTests var oldService = new FFmpegProcessService( new FakeStreamSelector(), - imageCache, Substitute.For(), Substitute.For(), - MemoryCache, LoggerFactory.CreateLogger()); var service = new FFmpegLibraryProcessService( diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 6cc197cfd..26212177f 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -727,6 +727,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();