403 lines
16 KiB
C#
403 lines
16 KiB
C#
using System.IO.Abstractions;
|
|
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(
|
|
IFileSystem fileSystem,
|
|
IImageCache imageCache,
|
|
IDecoSelector decoSelector,
|
|
ILogger<WatermarkSelector> logger)
|
|
: IWatermarkSelector
|
|
{
|
|
public List<WatermarkOptions> SelectWatermarks(
|
|
Option<ChannelWatermark> globalWatermark,
|
|
Channel channel,
|
|
PlayoutItem playoutItem,
|
|
DateTimeOffset now)
|
|
{
|
|
logger.LogDebug("Checking for watermark at {Now}", now);
|
|
|
|
var result = new List<WatermarkOptions>();
|
|
|
|
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)
|
|
{
|
|
var done = false;
|
|
|
|
switch (templateDeco.WatermarkMode)
|
|
{
|
|
case DecoMode.Merge:
|
|
if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller)
|
|
{
|
|
logger.LogDebug("Watermark will come from template deco (merge)");
|
|
result.AddRange(
|
|
OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark)));
|
|
break;
|
|
}
|
|
|
|
logger.LogDebug("Watermark is disabled by template deco during filler");
|
|
result.Clear();
|
|
done = true;
|
|
break;
|
|
case DecoMode.Override:
|
|
if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller)
|
|
{
|
|
logger.LogDebug("Watermark will come from template deco (replace)");
|
|
result.AddRange(
|
|
OptionsForWatermarks(channel, templateDeco.DecoWatermarks.Map(dwm => dwm.Watermark)));
|
|
done = true;
|
|
break;
|
|
}
|
|
|
|
logger.LogDebug("Watermark is disabled by template deco during filler");
|
|
result.Clear();
|
|
done = true;
|
|
break;
|
|
case DecoMode.Disable:
|
|
logger.LogDebug("Watermark is disabled by template deco");
|
|
done = true;
|
|
break;
|
|
case DecoMode.Inherit:
|
|
logger.LogDebug("Watermark will inherit from playout deco");
|
|
break;
|
|
}
|
|
|
|
if (done)
|
|
{
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// second, check playout deco
|
|
foreach (Deco playoutDeco in decoEntries.PlayoutDeco)
|
|
{
|
|
var done = false;
|
|
|
|
switch (playoutDeco.WatermarkMode)
|
|
{
|
|
case DecoMode.Merge:
|
|
if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller)
|
|
{
|
|
logger.LogDebug("Watermark will come from playout deco (merge)");
|
|
result.AddRange(
|
|
OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark)));
|
|
break;
|
|
}
|
|
|
|
logger.LogDebug("Watermark is disabled by playout deco during filler");
|
|
result.Clear();
|
|
done = true;
|
|
break;
|
|
case DecoMode.Override:
|
|
if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller)
|
|
{
|
|
logger.LogDebug("Watermark will come from playout deco (replace)");
|
|
result.AddRange(
|
|
OptionsForWatermarks(channel, playoutDeco.DecoWatermarks.Map(dwm => dwm.Watermark)));
|
|
done = true;
|
|
break;
|
|
}
|
|
|
|
logger.LogDebug("Watermark is disabled by playout deco during filler");
|
|
result.Clear();
|
|
done = true;
|
|
break;
|
|
case DecoMode.Disable:
|
|
logger.LogDebug("Watermark is disabled by playout deco");
|
|
done = true;
|
|
break;
|
|
case DecoMode.Inherit:
|
|
logger.LogDebug("Watermark will inherit from channel and/or global setting");
|
|
break;
|
|
}
|
|
|
|
if (done)
|
|
{
|
|
return result;
|
|
}
|
|
}
|
|
|
|
if (playoutItem.Watermarks.Count > 0)
|
|
{
|
|
foreach (var watermark in playoutItem.Watermarks)
|
|
{
|
|
Option<WatermarkOptions> options = GetWatermarkOptions(
|
|
channel,
|
|
watermark,
|
|
Option<ChannelWatermark>.None);
|
|
result.AddRange(options);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
var finalOptions = GetWatermarkOptions(channel, Option<ChannelWatermark>.None, globalWatermark);
|
|
result.AddRange(finalOptions);
|
|
|
|
return result;
|
|
}
|
|
|
|
public Option<WatermarkOptions> GetWatermarkOptions(
|
|
Channel channel,
|
|
Option<ChannelWatermark> playoutItemWatermark,
|
|
Option<ChannelWatermark> globalWatermark)
|
|
{
|
|
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect)
|
|
{
|
|
return Option<WatermarkOptions>.None;
|
|
}
|
|
|
|
// check for playout item watermark
|
|
foreach (ChannelWatermark watermark in playoutItemWatermark)
|
|
{
|
|
switch (watermark.ImageSource)
|
|
{
|
|
// used for song progress overlay
|
|
case ChannelWatermarkImageSource.Resource:
|
|
string resourcePath = fileSystem.Path.Combine(
|
|
FileSystemLayout.ResourcesCacheFolder,
|
|
watermark.Image);
|
|
if (fileSystem.File.Exists(resourcePath))
|
|
{
|
|
return new WatermarkOptions(watermark, resourcePath, Option<int>.None);
|
|
}
|
|
|
|
logger.LogWarning(
|
|
"Watermark resource no longer exists at {Path} and will be ignored",
|
|
resourcePath);
|
|
return 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<int>.None);
|
|
|
|
if (fileSystem.File.Exists(customPath))
|
|
{
|
|
return new WatermarkOptions(watermark, customPath, None);
|
|
}
|
|
|
|
logger.LogWarning(
|
|
"Custom watermark no longer exists at {Path} and will be ignored",
|
|
customPath);
|
|
return None;
|
|
case ChannelWatermarkImageSource.ChannelLogo:
|
|
logger.LogDebug("Watermark will come from playout item (channel logo)");
|
|
|
|
return ChannelLogoWatermarkOptions(channel, watermark);
|
|
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<int>.None);
|
|
|
|
if (fileSystem.File.Exists(customPath))
|
|
{
|
|
return new WatermarkOptions(channel.Watermark, customPath, None);
|
|
}
|
|
|
|
logger.LogWarning(
|
|
"Custom watermark no longer exists at {Path} and will be ignored",
|
|
customPath);
|
|
return None;
|
|
case ChannelWatermarkImageSource.ChannelLogo:
|
|
logger.LogDebug("Watermark will come from channel (channel logo)");
|
|
|
|
return ChannelLogoWatermarkOptions(channel, channel.Watermark);
|
|
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<int>.None);
|
|
|
|
if (fileSystem.File.Exists(customPath))
|
|
{
|
|
return new WatermarkOptions(watermark, customPath, None);
|
|
}
|
|
|
|
logger.LogWarning(
|
|
"Custom watermark no longer exists at {Path} and will be ignored",
|
|
customPath);
|
|
return None;
|
|
case ChannelWatermarkImageSource.ChannelLogo:
|
|
logger.LogDebug("Watermark will come from global (channel logo)");
|
|
|
|
return ChannelLogoWatermarkOptions(channel, watermark);
|
|
default:
|
|
throw new NotSupportedException("Unsupported watermark image source");
|
|
}
|
|
}
|
|
|
|
return Option<WatermarkOptions>.None;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a <see cref="ChannelWatermarkImageSource.ChannelLogo" /> watermark to a renderable path,
|
|
/// shared by the playout-item, channel and global precedence levels so all three agree.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// As of #525 an external-URL logo is downloaded and cached at save time, so a URL path here can only
|
|
/// be a row that failed migration. The render path must never fetch at compositing time, so such a row
|
|
/// degrades to no watermark (no on-screen bug) with a warning rather than being handed downstream as a
|
|
/// renderable URL (the #502 behavior). Other consumers (M3U, XMLTV, SPA JSON) still emit the raw URL for
|
|
/// a not-yet-migrated row; only this render/watermark path changed.
|
|
/// </remarks>
|
|
private Option<WatermarkOptions> ChannelLogoWatermarkOptions(Channel channel, ChannelWatermark watermark)
|
|
{
|
|
foreach (var logoArtwork in Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo)))
|
|
{
|
|
if (Artwork.IsExternalUrl(logoArtwork.Path))
|
|
{
|
|
// As of #525 an external-URL logo is downloaded and cached at save time, so a URL here
|
|
// means a row that failed migration. Do not fetch at render time; degrade to no bug.
|
|
logger.LogWarning(
|
|
"Channel logo for channel {Channel} is still an un-downloaded URL {Url}; re-save the "
|
|
+ "channel to download it. Rendering without an on-screen bug.",
|
|
channel.Number,
|
|
logoArtwork.Path);
|
|
return None;
|
|
}
|
|
|
|
string cachedPath = imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
|
|
if (fileSystem.File.Exists(cachedPath))
|
|
{
|
|
return new WatermarkOptions(watermark, cachedPath, None);
|
|
}
|
|
|
|
logger.LogWarning("Channel logo no longer exists at {Path} and will be ignored", cachedPath);
|
|
return None;
|
|
}
|
|
|
|
// with no logo artwork the only candidate is the generated-initials image, whose URL hardcodes
|
|
// localhost (ChannelLogoGenerator.GenerateChannelLogoUrl, issue #1). It has never rendered here and
|
|
// reviving it is deliberately deferred in docs/decisions.md, so it stays ignored.
|
|
logger.LogWarning(
|
|
"Channel logo no longer exists at {Path} and will be ignored",
|
|
ChannelLogoGenerator.GenerateChannelLogoUrl(channel));
|
|
return None;
|
|
}
|
|
|
|
private List<WatermarkOptions> OptionsForWatermarks(Channel channel, IEnumerable<ChannelWatermark> watermarks)
|
|
{
|
|
var result = new List<WatermarkOptions>();
|
|
|
|
foreach (var watermark in watermarks)
|
|
{
|
|
result.AddRange(GetWatermarkOptions(channel, watermark));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private Option<WatermarkOptions> 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<int>.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;
|
|
}
|
|
|
|
string customPath = imageCache.GetPathForImage(
|
|
watermark.Image,
|
|
ArtworkKind.Watermark,
|
|
Option<int>.None);
|
|
return new WatermarkOptions(
|
|
watermark,
|
|
customPath,
|
|
None);
|
|
case ChannelWatermarkImageSource.ChannelLogo:
|
|
// deliberately NOT ChannelLogoWatermarkOptions: the deco path has always passed its resolved
|
|
// path through unchecked, so #502's File.Exists defect never reached it and its *resolution*
|
|
// is unchanged here. Aligning its missing-file / no-artwork policy with the three precedence
|
|
// levels above is a behavior change beyond this fix — tracked in #510.
|
|
// Note this only scopes resolution: the ffmpeg-native-vs-graphics-engine routing in
|
|
// FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark keys off the resolved path alone, so a
|
|
// deco watermark resolving to a URL (an external logo, or the generated-initials URL below) is
|
|
// rerouted to the graphics engine like any other. That is intended: it is the URL-aware path.
|
|
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
|
|
Option<Artwork> 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<int>.None);
|
|
}
|
|
|
|
return new WatermarkOptions(watermark, channelPath, None);
|
|
default:
|
|
throw new NotSupportedException("Unsupported watermark image source");
|
|
}
|
|
|
|
return Option<WatermarkOptions>.None;
|
|
}
|
|
}
|