Files
ersatztv/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs
T

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;
}
}