Browse Source

feat: overlay single permanent watermarks using next engine (#2878)

pull/2879/head
Jason Dove 3 weeks ago committed by GitHub
parent
commit
e04a834edf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  2. 104
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  3. 3
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  4. 2
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  5. 5
      ErsatzTV.Core.Tests/FFmpeg/DecoSelectorTests.cs
  6. 2
      ErsatzTV.Core.Tests/FFmpeg/GraphicsElementSelectorTests.cs
  7. 5
      ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs
  8. 7
      ErsatzTV.Core/FFmpeg/DecoSelector.cs
  9. 98
      ErsatzTV.Core/FFmpeg/WatermarkSelector.cs
  10. 6
      ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs
  11. 262
      ErsatzTV.Core/Next/Playout.cs
  12. 8
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

7
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -46,6 +46,10 @@ public class UpdateChannelHandler(
} }
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg; bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
bool hasPlayoutChange = hasEpgChange || c.WatermarkId != update.WatermarkId ||
c.PreferredAudioLanguageCode != update.PreferredAudioLanguageCode ||
c.PreferredAudioTitle != update.PreferredAudioTitle ||
c.PreferredSubtitleLanguageCode != update.PreferredSubtitleLanguageCode;
c.Name = update.Name; c.Name = update.Name;
c.Number = update.Number; c.Number = update.Number;
@ -162,6 +166,9 @@ public class UpdateChannelHandler(
if (hasEpgChange) if (hasEpgChange)
{ {
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken); await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
}
if (hasPlayoutChange)
{
await workerChannel.WriteAsync(new SyncNextPlayout(c.Number), cancellationToken); await workerChannel.WriteAsync(new SyncNextPlayout(c.Number), cancellationToken);
} }

104
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -13,11 +13,13 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem; using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem;
using WatermarkLocation = ErsatzTV.FFmpeg.State.WatermarkLocation;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
@ -29,6 +31,7 @@ public partial class SyncNextPlayoutHandler(
IEmbyPathReplacementService embyPathReplacementService, IEmbyPathReplacementService embyPathReplacementService,
ICustomStreamSelector customStreamSelector, ICustomStreamSelector customStreamSelector,
IFFmpegStreamSelector ffmpegStreamSelector, IFFmpegStreamSelector ffmpegStreamSelector,
IWatermarkSelector watermarkSelector,
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ILogger<SyncNextPlayoutHandler> logger) ILogger<SyncNextPlayoutHandler> logger)
: IRequestHandler<SyncNextPlayout> : IRequestHandler<SyncNextPlayout>
@ -126,6 +129,28 @@ public partial class SyncNextPlayoutHandler(
List<PlayoutItem> playoutItems = await dbContext.PlayoutItems List<PlayoutItem> playoutItems = await dbContext.PlayoutItems
.AsNoTracking() .AsNoTracking()
.Where(i => i.Playout.Channel.Number == (mirrorChannelNumber ?? channelNumber)) .Where(i => i.Playout.Channel.Number == (mirrorChannelNumber ?? channelNumber))
// get playout deco
.Include(i => i.Playout)
.ThenInclude(p => p.Deco)
.ThenInclude(d => d.DecoWatermarks)
.ThenInclude(d => d.Watermark)
.Include(i => i.Playout)
.ThenInclude(p => p.Deco)
.ThenInclude(d => d.DecoGraphicsElements)
.ThenInclude(d => d.GraphicsElement)
.Include(i => i.Watermarks)
// get playout templates (and deco templates/decos)
.Include(i => i.Playout)
.ThenInclude(p => p.Templates)
.ThenInclude(t => t.DecoTemplate)
.ThenInclude(t => t.Items)
.ThenInclude(i => i.Deco)
.ThenInclude(d => d.DecoWatermarks)
.ThenInclude(d => d.Watermark)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => mi.LibraryPath) .ThenInclude(mi => mi.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
@ -167,6 +192,11 @@ public partial class SyncNextPlayoutHandler(
logger.LogDebug("Located {Count} local playout items", playoutItems.Count); logger.LogDebug("Located {Count} local playout items", playoutItems.Count);
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken)
.BindT(watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId, cancellationToken));
foreach (IGrouping<DateTime, PlayoutItem> group in playoutItems.GroupBy(pi => pi.StartOffset.Date) foreach (IGrouping<DateTime, PlayoutItem> group in playoutItems.GroupBy(pi => pi.StartOffset.Date)
.Where(g => g.Any())) .Where(g => g.Any()))
{ {
@ -239,6 +269,7 @@ public partial class SyncNextPlayoutHandler(
maybeChannel = await dbContext.Channels maybeChannel = await dbContext.Channels
.AsNoTracking() .AsNoTracking()
.Include(c => c.Watermark)
.SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken); .SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken);
foreach (Channel channel in maybeChannel) foreach (Channel channel in maybeChannel)
{ {
@ -252,6 +283,11 @@ public partial class SyncNextPlayoutHandler(
playoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode, playoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode,
playoutItem.SubtitleMode ?? channel.SubtitleMode, playoutItem.SubtitleMode ?? channel.SubtitleMode,
cancellationToken); cancellationToken);
await SelectWatermark(
maybeGlobalWatermark,
channel,
playoutItem,
nextPlayoutItem);
} }
playout.Items.Add(nextPlayoutItem); playout.Items.Add(nextPlayoutItem);
@ -346,6 +382,74 @@ public partial class SyncNextPlayoutHandler(
} }
} }
private async Task SelectWatermark(
Option<ChannelWatermark> maybeGlobalWatermark,
Channel channel,
PlayoutItem playoutItem,
Core.Next.PlayoutItem nextPlayoutItem)
{
List<WatermarkOptions> watermarks = watermarkSelector.SelectWatermarks(
maybeGlobalWatermark,
channel,
playoutItem,
playoutItem.StartOffset,
shouldLogMessages: false);
// single, permanent watermarks are supported
if (watermarks.Count == 1 && watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent))
{
foreach (WatermarkOptions watermarkOptions in watermarks)
{
if (nextPlayoutItem.Watermark is null)
{
Core.Next.WatermarkLocation location = watermarkOptions.Watermark.Location switch
{
WatermarkLocation.TopMiddle => Core.Next.WatermarkLocation.TopCenter,
WatermarkLocation.TopRight => Core.Next.WatermarkLocation.TopRight,
WatermarkLocation.LeftMiddle => Core.Next.WatermarkLocation.CenterLeft,
WatermarkLocation.MiddleCenter => Core.Next.WatermarkLocation.Center,
WatermarkLocation.RightMiddle => Core.Next.WatermarkLocation.CenterRight,
WatermarkLocation.BottomLeft => Core.Next.WatermarkLocation.BottomLeft,
WatermarkLocation.BottomMiddle => Core.Next.WatermarkLocation.BottomCenter,
WatermarkLocation.BottomRight => Core.Next.WatermarkLocation.BottomRight,
_ => Core.Next.WatermarkLocation.TopLeft,
};
nextPlayoutItem.Watermark = new Core.Next.Watermark
{
Location = location,
HorizontalMarginPercent = watermarkOptions.Watermark.HorizontalMarginPercent,
VerticalMarginPercent = watermarkOptions.Watermark.VerticalMarginPercent,
OpacityPercent = watermarkOptions.Watermark.Opacity,
StreamIndex = await watermarkOptions.ImageStreamIndex.IfNoneAsync(0),
};
if (watermarkOptions.Watermark.Size is WatermarkSize.Scaled)
{
nextPlayoutItem.Watermark.WidthPercent = watermarkOptions.Watermark.WidthPercent;
}
if (watermarkOptions.ImagePath.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource
{
SourceType = Core.Next.SourceType.Http,
Uri = watermarkOptions.ImagePath,
};
}
else
{
nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource
{
SourceType = Core.Next.SourceType.Local,
Path = watermarkOptions.ImagePath,
};
}
}
}
}
}
private async Task<Option<Core.Next.Source>> SourceForItem( private async Task<Option<Core.Next.Source>> SourceForItem(
PlayoutItem playoutItem, PlayoutItem playoutItem,
CancellationToken cancellationToken) CancellationToken cancellationToken)

3
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -369,7 +369,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
maybeGlobalWatermark, maybeGlobalWatermark,
channel, channel,
playoutItemWithPath.PlayoutItem, playoutItemWithPath.PlayoutItem,
now); now,
shouldLogMessages: true);
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song) if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
{ {

2
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -210,7 +210,7 @@ public class PrepareTroubleshootingPlaybackHandler(
foreach (var watermark in channelWatermarks) foreach (var watermark in channelWatermarks)
{ {
watermarks.AddRange( watermarks.AddRange(
watermarkSelector.GetWatermarkOptions(channel, watermark, Option<ChannelWatermark>.None)); watermarkSelector.GetWatermarkOptions(channel, watermark, Option<ChannelWatermark>.None, shouldLogMessages: true));
} }
} }

5
ErsatzTV.Core.Tests/FFmpeg/DecoSelectorTests.cs

@ -2,7 +2,6 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using Microsoft.Extensions.Logging;
using NUnit.Framework; using NUnit.Framework;
using Serilog; using Serilog;
using Shouldly; using Shouldly;
@ -21,9 +20,7 @@ public class DecoSelectorTests
.WriteTo.Console() .WriteTo.Console()
.CreateLogger(); .CreateLogger();
var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger); DecoSelector = new DecoSelector();
DecoSelector = new DecoSelector(loggerFactory.CreateLogger<DecoSelector>());
} }
[Test] [Test]

2
ErsatzTV.Core.Tests/FFmpeg/GraphicsElementSelectorTests.cs

@ -53,7 +53,7 @@ public class GraphicsElementSelectorTests
var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger); var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
GraphicsElementSelector = new GraphicsElementSelector( GraphicsElementSelector = new GraphicsElementSelector(
new DecoSelector(loggerFactory.CreateLogger<DecoSelector>()), new DecoSelector(),
loggerFactory.CreateLogger<GraphicsElementSelector>()); loggerFactory.CreateLogger<GraphicsElementSelector>());
GraphicsElementTemplateDeco = new GraphicsElement { Id = 1, Path = "Template Deco GE" }; GraphicsElementTemplateDeco = new GraphicsElement { Id = 1, Path = "Template Deco GE" };

5
ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorTests.cs

@ -78,7 +78,7 @@ public class WatermarkSelectorTests
WatermarkSelector = new WatermarkSelector( WatermarkSelector = new WatermarkSelector(
mockFileSystem, mockFileSystem,
fakeImageCache, fakeImageCache,
new DecoSelector(loggerFactory.CreateLogger<DecoSelector>()), new DecoSelector(),
loggerFactory.CreateLogger<WatermarkSelector>()); loggerFactory.CreateLogger<WatermarkSelector>());
WatermarkNone = Option<ChannelWatermark>.None; WatermarkNone = Option<ChannelWatermark>.None;
@ -568,7 +568,8 @@ public class WatermarkSelectorTests
td.globalWatermark, td.globalWatermark,
td.channel, td.channel,
td.playoutItem, td.playoutItem,
Now); Now,
shouldLogMessages: true);
watermarks.Count.ShouldBe(td.expectedWatermarks.Count); watermarks.Count.ShouldBe(td.expectedWatermarks.Count);
for (var i = 0; i < td.expectedWatermarks.Count; i++) for (var i = 0; i < td.expectedWatermarks.Count; i++)

7
ErsatzTV.Core/FFmpeg/DecoSelector.cs

@ -2,15 +2,14 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
public class DecoSelector(ILogger<DecoSelector> logger) : IDecoSelector public class DecoSelector : IDecoSelector
{ {
public DecoEntries GetDecoEntries(Playout playout, DateTimeOffset now) public DecoEntries GetDecoEntries(Playout playout, DateTimeOffset now)
{ {
logger.LogDebug("Checking for deco at {Now}", now); //logger.LogDebug("Checking for deco at {Now}", now);
if (playout is null) if (playout is null)
{ {
@ -30,7 +29,7 @@ public class DecoSelector(ILogger<DecoSelector> logger) : IDecoSelector
.Find(i => i.StartTime <= now.TimeOfDay && (i.EndTime == TimeSpan.Zero || i.EndTime > now.TimeOfDay)); .Find(i => i.StartTime <= now.TimeOfDay && (i.EndTime == TimeSpan.Zero || i.EndTime > now.TimeOfDay));
foreach (DecoTemplateItem item in maybeItem) foreach (DecoTemplateItem item in maybeItem)
{ {
logger.LogDebug("Selecting deco between {Start} and {End}", item.StartTime, item.EndTime); //logger.LogDebug("Selecting deco between {Start} and {End}", item.StartTime, item.EndTime);
maybeTemplateDeco = Optional(item.Deco); maybeTemplateDeco = Optional(item.Deco);
} }
} }

98
ErsatzTV.Core/FFmpeg/WatermarkSelector.cs

@ -6,6 +6,7 @@ using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
@ -20,9 +21,12 @@ public class WatermarkSelector(
Option<ChannelWatermark> globalWatermark, Option<ChannelWatermark> globalWatermark,
Channel channel, Channel channel,
PlayoutItem playoutItem, PlayoutItem playoutItem,
DateTimeOffset now) DateTimeOffset now,
bool shouldLogMessages)
{ {
logger.LogDebug("Checking for watermark at {Now}", now); ILogger<WatermarkSelector> log = shouldLogMessages ? logger : NullLogger<WatermarkSelector>.Instance;
log.LogDebug("Checking for watermark at {Now}", now);
var result = new List<WatermarkOptions>(); var result = new List<WatermarkOptions>();
@ -33,7 +37,7 @@ public class WatermarkSelector(
if (playoutItem.DisableWatermarks) if (playoutItem.DisableWatermarks)
{ {
logger.LogDebug("Watermark is disabled by playout item"); log.LogDebug("Watermark is disabled by playout item");
return result; return result;
} }
@ -49,36 +53,36 @@ public class WatermarkSelector(
case DecoMode.Merge: case DecoMode.Merge:
if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller) if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller)
{ {
logger.LogDebug("Watermark will come from template deco (merge)"); log.LogDebug("Watermark will come from template deco (merge)");
result.AddRange( result.AddRange(
OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark), log));
break; break;
} }
logger.LogDebug("Watermark is disabled by template deco during filler"); log.LogDebug("Watermark is disabled by template deco during filler");
result.Clear(); result.Clear();
done = true; done = true;
break; break;
case DecoMode.Override: case DecoMode.Override:
if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller) if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller)
{ {
logger.LogDebug("Watermark will come from template deco (replace)"); log.LogDebug("Watermark will come from template deco (replace)");
result.AddRange( result.AddRange(
OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark), log));
done = true; done = true;
break; break;
} }
logger.LogDebug("Watermark is disabled by template deco during filler"); log.LogDebug("Watermark is disabled by template deco during filler");
result.Clear(); result.Clear();
done = true; done = true;
break; break;
case DecoMode.Disable: case DecoMode.Disable:
logger.LogDebug("Watermark is disabled by template deco"); log.LogDebug("Watermark is disabled by template deco");
done = true; done = true;
break; break;
case DecoMode.Inherit: case DecoMode.Inherit:
logger.LogDebug("Watermark will inherit from playout deco"); log.LogDebug("Watermark will inherit from playout deco");
break; break;
} }
@ -98,36 +102,36 @@ public class WatermarkSelector(
case DecoMode.Merge: case DecoMode.Merge:
if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller) if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller)
{ {
logger.LogDebug("Watermark will come from playout deco (merge)"); log.LogDebug("Watermark will come from playout deco (merge)");
result.AddRange( result.AddRange(
OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark), log));
break; break;
} }
logger.LogDebug("Watermark is disabled by playout deco during filler"); log.LogDebug("Watermark is disabled by playout deco during filler");
result.Clear(); result.Clear();
done = true; done = true;
break; break;
case DecoMode.Override: case DecoMode.Override:
if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller) if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller)
{ {
logger.LogDebug("Watermark will come from playout deco (replace)"); log.LogDebug("Watermark will come from playout deco (replace)");
result.AddRange( result.AddRange(
OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark))); OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark), log));
done = true; done = true;
break; break;
} }
logger.LogDebug("Watermark is disabled by playout deco during filler"); log.LogDebug("Watermark is disabled by playout deco during filler");
result.Clear(); result.Clear();
done = true; done = true;
break; break;
case DecoMode.Disable: case DecoMode.Disable:
logger.LogDebug("Watermark is disabled by playout deco"); log.LogDebug("Watermark is disabled by playout deco");
done = true; done = true;
break; break;
case DecoMode.Inherit: case DecoMode.Inherit:
logger.LogDebug("Watermark will inherit from channel and/or global setting"); log.LogDebug("Watermark will inherit from channel and/or global setting");
break; break;
} }
@ -144,7 +148,8 @@ public class WatermarkSelector(
Option<WatermarkOptions> options = GetWatermarkOptions( Option<WatermarkOptions> options = GetWatermarkOptions(
channel, channel,
watermark, watermark,
Option<ChannelWatermark>.None); Option<ChannelWatermark>.None,
shouldLogMessages);
result.AddRange(options); result.AddRange(options);
} }
@ -152,7 +157,11 @@ public class WatermarkSelector(
} }
var finalOptions = GetWatermarkOptions(channel, Option<ChannelWatermark>.None, globalWatermark); Option<WatermarkOptions> finalOptions = GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
globalWatermark,
shouldLogMessages);
result.AddRange(finalOptions); result.AddRange(finalOptions);
return result; return result;
@ -161,8 +170,11 @@ public class WatermarkSelector(
public Option<WatermarkOptions> GetWatermarkOptions( public Option<WatermarkOptions> GetWatermarkOptions(
Channel channel, Channel channel,
Option<ChannelWatermark> playoutItemWatermark, Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark) Option<ChannelWatermark> globalWatermark,
bool shouldLogMessages)
{ {
ILogger<WatermarkSelector> log = shouldLogMessages ? logger : NullLogger<WatermarkSelector>.Instance;
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect) if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect)
{ {
return Option<WatermarkOptions>.None; return Option<WatermarkOptions>.None;
@ -183,7 +195,7 @@ public class WatermarkSelector(
return new WatermarkOptions(watermark, resourcePath, Option<int>.None); return new WatermarkOptions(watermark, resourcePath, Option<int>.None);
} }
logger.LogWarning( log.LogWarning(
"Watermark resource no longer exists at {Path} and will be ignored", "Watermark resource no longer exists at {Path} and will be ignored",
resourcePath); resourcePath);
return None; return None;
@ -191,13 +203,13 @@ public class WatermarkSelector(
// bad form validation makes this possible // bad form validation makes this possible
if (string.IsNullOrWhiteSpace(watermark.Image)) if (string.IsNullOrWhiteSpace(watermark.Image))
{ {
logger.LogWarning( log.LogWarning(
"Watermark {Name} has custom image configured with no image; ignoring", "Watermark {Name} has custom image configured with no image; ignoring",
watermark.Name); watermark.Name);
break; break;
} }
logger.LogDebug("Watermark will come from playout item (custom)"); log.LogDebug("Watermark will come from playout item (custom)");
string customPath = imageCache.GetPathForImage( string customPath = imageCache.GetPathForImage(
watermark.Image, watermark.Image,
@ -209,12 +221,12 @@ public class WatermarkSelector(
return new WatermarkOptions(watermark, customPath, None); return new WatermarkOptions(watermark, customPath, None);
} }
logger.LogWarning( log.LogWarning(
"Custom watermark no longer exists at {Path} and will be ignored", "Custom watermark no longer exists at {Path} and will be ignored",
customPath); customPath);
return None; return None;
case ChannelWatermarkImageSource.ChannelLogo: case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from playout item (channel logo)"); log.LogDebug("Watermark will come from playout item (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork = Option<Artwork> maybeLogoArtwork =
@ -231,7 +243,7 @@ public class WatermarkSelector(
return new WatermarkOptions(watermark, channelPath, None); return new WatermarkOptions(watermark, channelPath, None);
} }
logger.LogWarning( log.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored", "Channel logo no longer exists at {Path} and will be ignored",
channelPath); channelPath);
return None; return None;
@ -246,7 +258,7 @@ public class WatermarkSelector(
switch (channel.Watermark.ImageSource) switch (channel.Watermark.ImageSource)
{ {
case ChannelWatermarkImageSource.Custom: case ChannelWatermarkImageSource.Custom:
logger.LogDebug("Watermark will come from channel (custom)"); log.LogDebug("Watermark will come from channel (custom)");
string customPath = imageCache.GetPathForImage( string customPath = imageCache.GetPathForImage(
channel.Watermark.Image, channel.Watermark.Image,
@ -258,12 +270,12 @@ public class WatermarkSelector(
return new WatermarkOptions(channel.Watermark, customPath, None); return new WatermarkOptions(channel.Watermark, customPath, None);
} }
logger.LogWarning( log.LogWarning(
"Custom watermark no longer exists at {Path} and will be ignored", "Custom watermark no longer exists at {Path} and will be ignored",
customPath); customPath);
return None; return None;
case ChannelWatermarkImageSource.ChannelLogo: case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from channel (channel logo)"); log.LogDebug("Watermark will come from channel (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork = Option<Artwork> maybeLogoArtwork =
@ -280,7 +292,7 @@ public class WatermarkSelector(
return new WatermarkOptions(channel.Watermark, channelPath, None); return new WatermarkOptions(channel.Watermark, channelPath, None);
} }
logger.LogWarning( log.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored", "Channel logo no longer exists at {Path} and will be ignored",
channelPath); channelPath);
return None; return None;
@ -295,7 +307,7 @@ public class WatermarkSelector(
switch (watermark.ImageSource) switch (watermark.ImageSource)
{ {
case ChannelWatermarkImageSource.Custom: case ChannelWatermarkImageSource.Custom:
logger.LogDebug("Watermark will come from global (custom)"); log.LogDebug("Watermark will come from global (custom)");
string customPath = imageCache.GetPathForImage( string customPath = imageCache.GetPathForImage(
watermark.Image, watermark.Image,
@ -307,12 +319,12 @@ public class WatermarkSelector(
return new WatermarkOptions(watermark, customPath, None); return new WatermarkOptions(watermark, customPath, None);
} }
logger.LogWarning( log.LogWarning(
"Custom watermark no longer exists at {Path} and will be ignored", "Custom watermark no longer exists at {Path} and will be ignored",
customPath); customPath);
return None; return None;
case ChannelWatermarkImageSource.ChannelLogo: case ChannelWatermarkImageSource.ChannelLogo:
logger.LogDebug("Watermark will come from global (channel logo)"); log.LogDebug("Watermark will come from global (channel logo)");
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel); string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
Option<Artwork> maybeLogoArtwork = Option<Artwork> maybeLogoArtwork =
@ -329,7 +341,7 @@ public class WatermarkSelector(
return new WatermarkOptions(watermark, channelPath, None); return new WatermarkOptions(watermark, channelPath, None);
} }
logger.LogWarning( log.LogWarning(
"Channel logo no longer exists at {Path} and will be ignored", "Channel logo no longer exists at {Path} and will be ignored",
channelPath); channelPath);
return None; return None;
@ -341,19 +353,25 @@ public class WatermarkSelector(
return Option<WatermarkOptions>.None; return Option<WatermarkOptions>.None;
} }
private List<WatermarkOptions> OptionsForWatermarks(Channel channel, IEnumerable<ChannelWatermark> watermarks) private List<WatermarkOptions> OptionsForWatermarks(
Channel channel,
IEnumerable<ChannelWatermark> watermarks,
ILogger<WatermarkSelector> log)
{ {
var result = new List<WatermarkOptions>(); var result = new List<WatermarkOptions>();
foreach (var watermark in watermarks) foreach (var watermark in watermarks)
{ {
result.AddRange(GetWatermarkOptions(channel, watermark)); result.AddRange(GetWatermarkOptions(channel, watermark, log));
} }
return result; return result;
} }
private Option<WatermarkOptions> GetWatermarkOptions(Channel channel, ChannelWatermark watermark) private Option<WatermarkOptions> GetWatermarkOptions(
Channel channel,
ChannelWatermark watermark,
ILogger<WatermarkSelector> log)
{ {
switch (watermark.ImageSource) switch (watermark.ImageSource)
{ {
@ -367,7 +385,7 @@ public class WatermarkSelector(
// bad form validation makes this possible // bad form validation makes this possible
if (string.IsNullOrWhiteSpace(watermark.Image)) if (string.IsNullOrWhiteSpace(watermark.Image))
{ {
logger.LogWarning( log.LogWarning(
"Watermark {Name} has custom image configured with no image; ignoring", "Watermark {Name} has custom image configured with no image; ignoring",
watermark.Name); watermark.Name);
break; break;

6
ErsatzTV.Core/Interfaces/FFmpeg/IWatermarkSelector.cs

@ -9,10 +9,12 @@ public interface IWatermarkSelector
Option<ChannelWatermark> globalWatermark, Option<ChannelWatermark> globalWatermark,
Channel channel, Channel channel,
PlayoutItem playoutItem, PlayoutItem playoutItem,
DateTimeOffset now); DateTimeOffset now,
bool shouldLogMessages);
Option<WatermarkOptions> GetWatermarkOptions( Option<WatermarkOptions> GetWatermarkOptions(
Channel channel, Channel channel,
Option<ChannelWatermark> playoutItemWatermark, Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark); Option<ChannelWatermark> globalWatermark,
bool shouldLogMessages);
} }

262
ErsatzTV.Core/Next/Playout.cs

@ -84,6 +84,13 @@ namespace ErsatzTV.Core.Next
/// </summary> /// </summary>
[JsonProperty("tracks")] [JsonProperty("tracks")]
public PlayoutItemTracks Tracks { get; set; } public PlayoutItemTracks Tracks { get; set; }
/// <summary>
/// Watermark (image/video overlay) to composite on top of the primary content for the
/// duration of this item. Omit for no watermark.
/// </summary>
[JsonProperty("watermark")]
public Watermark Watermark { get; set; }
} }
/// <summary> /// <summary>
@ -209,8 +216,152 @@ namespace ErsatzTV.Core.Next
public long? StreamIndex { get; set; } public long? StreamIndex { get; set; }
} }
/// <summary>
/// An image or video overlay composited on top of the primary content. Sized and positioned
/// relative to the primary content's frame.
/// </summary>
public partial class Watermark
{
/// <summary>
/// Horizontal offset from the anchor `location`, as a percent of primary content width
/// (0–100). Omit for 0.
/// </summary>
[JsonProperty("horizontal_margin_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? HorizontalMarginPercent { get; set; }
/// <summary>
/// Anchor position within the primary content frame.
/// </summary>
[JsonProperty("location")]
public WatermarkLocation Location { get; set; }
/// <summary>
/// Opacity as a percent (0–100). Omit for fully opaque (100).
/// </summary>
[JsonProperty("opacity_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? OpacityPercent { get; set; }
/// <summary>
/// The source providing the watermark media (typically an image, but any `PlayoutItemSource`
/// is accepted).
/// </summary>
[JsonProperty("source")]
public PlayoutItemSource Source { get; set; }
/// <summary>
/// Zero-based stream index within the source. If omitted, the server picks the first video
/// stream.
/// </summary>
[JsonProperty("stream_index")]
public long? StreamIndex { get; set; }
/// <summary>
/// Vertical offset from the anchor `location`, as a percent of primary content height
/// (0–100). Omit for 0.
/// </summary>
[JsonProperty("vertical_margin_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? VerticalMarginPercent { get; set; }
/// <summary>
/// Scale the watermark to this percent of the primary content width (0–100). Omit to use the
/// watermark's actual size.
/// </summary>
[JsonProperty("width_percent")]
[JsonConverter(typeof(MinMaxValueCheckConverter))]
public double? WidthPercent { get; set; }
}
/// <summary>
/// A media source. Exactly one variant, distinguished by `source_type`.
///
/// The source providing the watermark media (typically an image, but any `PlayoutItemSource`
/// is accepted).
///
/// A file on the local filesystem reachable by the server.
///
/// A synthetic source produced by an ffmpeg lavfi filter graph.
///
/// A remote source fetched over HTTP(S).
/// </summary>
public partial class PlayoutItemSource
{
/// <summary>
/// Optional start offset into the source, in milliseconds.
/// </summary>
[JsonProperty("in_point_ms")]
public long? InPointMs { get; set; }
/// <summary>
/// Optional end offset into the source, in milliseconds.
/// </summary>
[JsonProperty("out_point_ms")]
public long? OutPointMs { get; set; }
/// <summary>
/// Absolute path to the media file.
/// </summary>
[JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)]
public string Path { get; set; }
[JsonProperty("source_type")]
public SourceType SourceType { get; set; }
/// <summary>
/// The lavfi filter graph parameters, passed verbatim to ffmpeg's `-f lavfi -i`.
/// </summary>
[JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)]
public string Params { get; set; }
/// <summary>
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"].
/// </summary>
[JsonProperty("headers")]
public List<string> Headers { get; set; }
/// <summary>
/// Enable reconnect on failure. Default: true.
/// </summary>
[JsonProperty("reconnect")]
public bool? Reconnect { get; set; }
/// <summary>
/// Maximum reconnect delay in seconds. Maps to ffmpeg's `reconnect_delay_max`.
/// </summary>
[JsonProperty("reconnect_delay_max")]
public long? ReconnectDelayMax { get; set; }
/// <summary>
/// Socket timeout in microseconds.
/// </summary>
[JsonProperty("timeout_us")]
public long? TimeoutUs { get; set; }
/// <summary>
/// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}".
/// </summary>
[JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)]
public string Uri { get; set; }
/// <summary>
/// Custom User-Agent string.
/// </summary>
[JsonProperty("user_agent")]
public string UserAgent { get; set; }
}
public enum SourceType { Http, Lavfi, Local }; public enum SourceType { Http, Lavfi, Local };
/// <summary>
/// Anchor position within the primary content frame.
///
/// Nine-position anchor within the primary content frame. Read like a 3×3 grid: rows
/// top/center/bottom, columns left/center/right; the dead center is `center`.
/// </summary>
public enum WatermarkLocation { BottomCenter, BottomLeft, BottomRight, Center, CenterLeft, CenterRight, TopCenter, TopLeft, TopRight };
public partial class Playout public partial class Playout
{ {
public static Playout FromJson(string json) => JsonConvert.DeserializeObject<Playout>(json, ErsatzTV.Core.Next.Converter.Settings); public static Playout FromJson(string json) => JsonConvert.DeserializeObject<Playout>(json, ErsatzTV.Core.Next.Converter.Settings);
@ -231,6 +382,7 @@ namespace ErsatzTV.Core.Next
Converters = Converters =
{ {
SourceTypeConverter.Singleton, SourceTypeConverter.Singleton,
WatermarkLocationConverter.Singleton,
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
}, },
}; };
@ -281,4 +433,114 @@ namespace ErsatzTV.Core.Next
public static readonly SourceTypeConverter Singleton = new SourceTypeConverter(); public static readonly SourceTypeConverter Singleton = new SourceTypeConverter();
} }
internal class MinMaxValueCheckConverter : JsonConverter
{
public override bool CanConvert(Type t) => t == typeof(double) || t == typeof(double?);
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null) return null;
var value = serializer.Deserialize<double>(reader);
if (value >= 0 && value <= 100)
{
return value;
}
throw new Exception("Cannot unmarshal type double");
}
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
{
if (untypedValue == null)
{
serializer.Serialize(writer, null);
return;
}
var value = (double)untypedValue;
if (value >= 0 && value <= 100)
{
serializer.Serialize(writer, value);
return;
}
throw new Exception("Cannot marshal type double");
}
public static readonly MinMaxValueCheckConverter Singleton = new MinMaxValueCheckConverter();
}
internal class WatermarkLocationConverter : JsonConverter
{
public override bool CanConvert(Type t) => t == typeof(WatermarkLocation) || t == typeof(WatermarkLocation?);
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null) return null;
var value = serializer.Deserialize<string>(reader);
switch (value)
{
case "bottom_center":
return WatermarkLocation.BottomCenter;
case "bottom_left":
return WatermarkLocation.BottomLeft;
case "bottom_right":
return WatermarkLocation.BottomRight;
case "center":
return WatermarkLocation.Center;
case "center_left":
return WatermarkLocation.CenterLeft;
case "center_right":
return WatermarkLocation.CenterRight;
case "top_center":
return WatermarkLocation.TopCenter;
case "top_left":
return WatermarkLocation.TopLeft;
case "top_right":
return WatermarkLocation.TopRight;
}
throw new Exception("Cannot unmarshal type WatermarkLocation");
}
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
{
if (untypedValue == null)
{
serializer.Serialize(writer, null);
return;
}
var value = (WatermarkLocation)untypedValue;
switch (value)
{
case WatermarkLocation.BottomCenter:
serializer.Serialize(writer, "bottom_center");
return;
case WatermarkLocation.BottomLeft:
serializer.Serialize(writer, "bottom_left");
return;
case WatermarkLocation.BottomRight:
serializer.Serialize(writer, "bottom_right");
return;
case WatermarkLocation.Center:
serializer.Serialize(writer, "center");
return;
case WatermarkLocation.CenterLeft:
serializer.Serialize(writer, "center_left");
return;
case WatermarkLocation.CenterRight:
serializer.Serialize(writer, "center_right");
return;
case WatermarkLocation.TopCenter:
serializer.Serialize(writer, "top_center");
return;
case WatermarkLocation.TopLeft:
serializer.Serialize(writer, "top_left");
return;
case WatermarkLocation.TopRight:
serializer.Serialize(writer, "top_right");
return;
}
throw new Exception("Cannot marshal type WatermarkLocation");
}
public static readonly WatermarkLocationConverter Singleton = new WatermarkLocationConverter();
}
} }

8
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -375,13 +375,13 @@ public class TranscodingTests
WatermarkSelector watermarkSelector = new WatermarkSelector( WatermarkSelector watermarkSelector = new WatermarkSelector(
new MockFileSystem(), new MockFileSystem(),
mockImageCache, mockImageCache,
new DecoSelector(LoggerFactory.CreateLogger<DecoSelector>()), new DecoSelector(),
LoggerFactory.CreateLogger<WatermarkSelector>()); LoggerFactory.CreateLogger<WatermarkSelector>());
List<WatermarkOptions> watermarks = []; List<WatermarkOptions> watermarks = [];
foreach (var wm in GetWatermark(watermark)) foreach (var wm in GetWatermark(watermark))
{ {
watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option<ChannelWatermark>.None)); watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option<ChannelWatermark>.None, shouldLogMessages: true));
} }
PlayoutItemResult playoutItemResult = await service.ForPlayoutItem( PlayoutItemResult playoutItemResult = await service.ForPlayoutItem(
@ -707,13 +707,13 @@ public class TranscodingTests
WatermarkSelector watermarkSelector = new WatermarkSelector( WatermarkSelector watermarkSelector = new WatermarkSelector(
new RealFileSystem(), new RealFileSystem(),
mockImageCache, mockImageCache,
new DecoSelector(LoggerFactory.CreateLogger<DecoSelector>()), new DecoSelector(),
LoggerFactory.CreateLogger<WatermarkSelector>()); LoggerFactory.CreateLogger<WatermarkSelector>());
List<WatermarkOptions> watermarks = []; List<WatermarkOptions> watermarks = [];
foreach (var wm in channelWatermark) foreach (var wm in channelWatermark)
{ {
watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option<ChannelWatermark>.None)); watermarks.AddRange(watermarkSelector.GetWatermarkOptions(channel, wm, Option<ChannelWatermark>.None, shouldLogMessages: true));
} }
var mediaItem = new OtherVideo { MediaVersions = [v] }; var mediaItem = new OtherVideo { MediaVersions = [v] };

Loading…
Cancel
Save