Files
ersatztv/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs
T
timothy edf8be4b5e
PR Gates / Docs update reminder (pull_request) Successful in 18s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
review-verdict/h10 Awaiting review verdict for edf8be4
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Review verdict / Set review-verdict status (pull_request) Successful in 3s
PR Gates / Script tests (pytest) (pull_request) Failing after 38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m40s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(510): re-review round — make two review-added tests actually falsifiable
Re-review of the previous fix commit found that two tests added to close
round-1 findings could not fail. Both verified before fixing:

1. Missing_But_Named_Custom_Playout_Item_Watermark_Should_Not_Fall_Through
   gave the channel-level fallback the SAME missing custom path as the
   playout-item watermark, so a wrongly-widened guard would have fallen
   through to a fallback that also resolved to None -- the assertion held
   either way. The fallback is now an independently resolvable ChannelLogo
   whose cached file exists, so a fall-through returns it and fails the test.
   Added the matching positive control (blank -> falls through and DOES
   return that logo), so the pair shows the guard distinguishes blank from
   unresolvable instead of both landing on None.

2. Deco_With_One_Valid_And_One_Missing_Watermark... asserted a filtered list
   length while the routing claim the decision record cited it for lives in
   FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark, which the test
   never called. It now calls the real predicate.

Also, three wrong claims of my own:

3. The Resource arm comment said "nothing in the app writes a Resource
   watermark to the database". False -- CreateWatermarkHandler and
   UpdateWatermarkHandler persist whatever ImageSource the request names, so
   a Resource watermark IS creatable through the API, always with
   Image = null. That is precisely why the new null guard is load-bearing,
   so the comment was arguing for its own removal.

4. "One resolver and no per-caller policy" contradicted the surviving
   playout-item blank-Custom fall-through documented a few lines later.
   Reworded in both the record and the XML docs: one resolver, and exactly
   one piece of per-caller policy which lives in the CALLER.

5. The record's "12 of 18 new tests fail pre-fix" was stale. Re-measured
   against the final fixture: 19 of 29. The other 10 pass both ways by
   design because they pin preserved behavior, which the record now says
   explicitly rather than leaving the gap to be read as weakness.

Removed the vacuous generated-URL test rather than keeping it with an honest
comment -- an empty list trivially contains no URL, so it implied coverage it
never had. Its assertion is folded into the sibling test that has a real
arrangement.

Gates: 2772 tests green across 5 projects, dotnet format exit 0, no BOMs,
decisions-validate OK, live-E2E re-run against this binary (0 changed pixels,
nameplate absent, warning emitted).

refs #510
2026-07-26 21:29:11 +02:00

387 lines
18 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)
{
// A custom watermark with no image at all is a bad-form-validation artifact, and it has always
// fallen THROUGH to the channel/global watermark rather than resolving to "no watermark". That
// stays true: unifying *resolution* (#510) must not change which watermark WINS.
if (watermark.ImageSource is ChannelWatermarkImageSource.Custom
&& 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 ({ImageSource})", watermark.ImageSource);
return ResolveWatermark(channel, watermark);
}
// check for channel watermark
if (channel.Watermark != null)
{
logger.LogDebug("Watermark will come from channel ({ImageSource})", channel.Watermark.ImageSource);
return ResolveWatermark(channel, channel.Watermark);
}
// check for global watermark
foreach (ChannelWatermark watermark in globalWatermark)
{
logger.LogDebug("Watermark will come from global ({ImageSource})", watermark.ImageSource);
return ResolveWatermark(channel, watermark);
}
return Option<WatermarkOptions>.None;
}
/// <summary>
/// The single place a <see cref="ChannelWatermark" /> becomes a renderable image path, shared by every
/// watermark source: the three precedence levels (playout item, channel, global) AND the deco path.
/// </summary>
/// <remarks>
/// Before #510 the deco path had its own copy of this switch that resolved paths *unchecked* — it handed
/// down a nonexistent file, an un-migrated external URL, and the generated-initials localhost URL. The
/// playout-item level checked all three sources; the channel and global levels checked
/// <c>Custom</c>/<c>ChannelLogo</c> and *threw* for <c>Resource</c> (no arm, so `default:`). So the same
/// channel could disagree with itself about whether a bug rendered, purely by how the watermark was
/// attached. Duplication is what let that drift happen (it existed in triplicate before #502), so there is
/// now one resolver. Exactly one piece of per-caller policy survives, and it lives in the CALLER rather
/// than here: a playout-item <c>Custom</c> watermark with a blank image falls through to channel/global
/// (see <see cref="GetWatermarkOptions" />). An unresolvable watermark resolves to "no on-screen bug",
/// never a dead path passed downstream: a dead LOCAL path could reach ffmpeg as a bare <c>-i</c> argument
/// via <c>CanUseFFmpegNativeWatermark</c>, which is materially worse than a skipped overlay.
/// <para>
/// Watermarks built OUTSIDE this selector are not covered — the song-progress overlay is constructed as a
/// <c>WatermarkOptions</c> directly by the streaming and troubleshooting handlers and is still unchecked
/// (#653).
/// </para>
/// </remarks>
private Option<WatermarkOptions> ResolveWatermark(Channel channel, ChannelWatermark watermark)
{
switch (watermark.ImageSource)
{
// NOT dead code and NOT only hand-edited rows: CreateWatermarkHandler/UpdateWatermarkHandler
// persist whatever ImageSource the request names, so a Resource watermark is creatable through
// the API -- always with Image = null, which is why the guard below is essential.
// Separately, the real song-progress overlay does NOT come through here: it is built directly as a
// WatermarkOptions by the streaming/troubleshooting handlers, which bypass this resolver and are
// still unchecked (#653).
case ChannelWatermarkImageSource.Resource:
// Image is NULL for every non-Custom watermark the API writes (CreateWatermarkHandler /
// UpdateWatermarkHandler both set `Image = null` unless ImageSource is Custom), so this guard is
// load-bearing, not defensive: Path.Combine(folder, null) throws ArgumentNullException, which
// would surface as a failed stream start rather than a missing overlay.
if (string.IsNullOrWhiteSpace(watermark.Image))
{
logger.LogWarning(
"Watermark {Name} uses a resource image but has no image name; ignoring",
watermark.Name);
return None;
}
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);
return None;
}
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:
return ChannelLogoWatermarkOptions(channel, watermark);
// deliberately loud: a newly-added image source must fail visibly rather than silently
// resolve to some neighbouring source's behavior
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
/// <summary>
/// Resolves a <see cref="ChannelWatermarkImageSource.ChannelLogo" /> watermark to a renderable path.
/// Since #510 this is reached from <see cref="ResolveWatermark" />, so all FOUR sources — the playout-item,
/// channel and global precedence levels AND the deco path — 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 at all the only candidate is the generated-initials image, served over HTTP from
// ChannelLogoGenerator.GenerateChannelLogoUrl -- a URL that hardcodes localhost (issue #1, closed as a
// topology problem without removing the hardcode).
//
// Until #510 that URL WAS returned by the deco path, and it genuinely rendered: a live-E2E on a real
// transcoded frame confirmed the nameplate compositing through the graphics engine (the /iptv/logos/gen
// route sits on ArtworkController, which carries no auth filter, so the container-internal self-fetch
// succeeded). It never rendered at the three precedence levels. #510 resolved that split in favour of
// "no bug", because a render-time HTTP fetch inside stream startup is exactly what `graphics.channel-logo-caching`
// (#525) eliminated for logos -- so the fallback is now off everywhere rather than on for one caller.
// Reviving it properly means generating the image into the image cache so it resolves to a LOCAL path;
// that is deliberately out of scope here and tracked separately.
logger.LogWarning(
"Channel {Channel} has no logo artwork; rendering without an on-screen bug. The generated-initials "
+ "fallback ({Url}) is deliberately not used by the render path",
channel.Number,
ChannelLogoGenerator.GenerateChannelLogoUrl(channel));
return None;
}
/// <summary>
/// Resolves the watermarks attached to a deco. Since #510 this shares <see cref="ResolveWatermark" />
/// with the three precedence levels rather than carrying its own unchecked copy of the same switch.
/// </summary>
/// <remarks>
/// Resolution is now identical to the precedence levels; what stays deco-specific is only WHICH
/// watermarks apply and whether they merge with or override the rest (handled in
/// <see cref="SelectWatermarks" />).
/// <para>
/// The routing PREDICATE is unchanged — <c>CanUseFFmpegNativeWatermark</c> still keys off the resolved
/// path alone and sends any URL to the graphics engine regardless of provenance. Its INPUT can change,
/// though: dropping an unresolvable watermark shortens this list, so a deco carrying one valid and one
/// missing permanent watermark now yields count 1 (ffmpeg-native) where it previously yielded count 2
/// (graphics engine). That is intended — the surviving watermark is a single valid permanent local image,
/// exactly what the native path is for — but it IS an observable routing change, not a no-op.
/// </para>
/// </remarks>
private List<WatermarkOptions> OptionsForWatermarks(Channel channel, IEnumerable<ChannelWatermark> watermarks)
{
var result = new List<WatermarkOptions>();
foreach (var watermark in watermarks)
{
result.AddRange(ResolveWatermark(channel, watermark));
}
return result;
}
}