fix(510): one watermark resolver for all four attachment points #655
@@ -0,0 +1,589 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Pins ersatztv#510: a watermark attached through a DECO resolves by exactly the same policy as the three
|
||||
/// precedence levels (playout item, channel, global).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Before #510 the deco path had its own copy of the image-source switch that resolved paths *unchecked*, so
|
||||
/// one channel could disagree with itself about whether a bug rendered purely by how the watermark was
|
||||
/// attached. The divergence covered all three <see cref="ChannelWatermarkImageSource" /> values, not just
|
||||
/// <c>ChannelLogo</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item>a missing local file was handed downstream as a dead path (and a dead LOCAL path can reach
|
||||
/// ffmpeg as a bare <c>-i</c> argument via <c>CanUseFFmpegNativeWatermark</c>, so it is worse than a
|
||||
/// skipped overlay);</item>
|
||||
/// <item>an un-migrated external-URL logo was handed down as a renderable URL, which
|
||||
/// <c>graphics.channel-logo-caching</c> (#525) forbids the render path from fetching;</item>
|
||||
/// <item>a channel with no logo artwork got the generated-initials localhost URL, which a live-E2E on a
|
||||
/// real transcoded frame confirmed DID render — the deco path only. #510 resolved that split in favour
|
||||
/// of "no on-screen bug" everywhere.</item>
|
||||
/// </list>
|
||||
/// The <c>Deco_And_Channel_Level_Should_Resolve_Identically</c> cases are the structural guard: they assert
|
||||
/// the two callers agree, so re-introducing a per-caller policy fails here rather than silently in prod.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public class WatermarkSelectorDecoResolutionTests
|
||||
{
|
||||
private const string ExternalLogoUrl = "https://cdn.example.com/logos/channel.png";
|
||||
private const string LogoStoredPath = "abc123.png";
|
||||
private const string LogoCachePath = "/cache/logos/ab/abc123.png";
|
||||
private const string CustomStoredPath = "def456.png";
|
||||
private const string CustomCachePath = "/cache/watermarks/de/def456.png";
|
||||
private const string ResourceImage = "song-progress.png";
|
||||
|
||||
private static string ResourcePath => Path.Combine(FileSystemLayout.ResourcesCacheFolder, ResourceImage);
|
||||
|
||||
/// <summary>Builds a selector whose mock filesystem contains exactly <paramref name="existingFiles" />.</summary>
|
||||
private static WatermarkSelector Selector(Deco playoutDeco, params string[] existingFiles)
|
||||
{
|
||||
// one Initialize() call, chained -- calling it per file would leave "does a second Initialize()
|
||||
// preserve the first file?" untested, and a silently under-seeded filesystem makes a
|
||||
// "resolves to nothing" assertion pass for the wrong reason
|
||||
var mockFileSystem = new MockFileSystem();
|
||||
if (existingFiles.Length > 0)
|
||||
{
|
||||
var initialized = mockFileSystem.Initialize().WithFile(existingFiles[0]);
|
||||
foreach (string file in existingFiles.Skip(1))
|
||||
{
|
||||
initialized = initialized.WithFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
var fakeImageCache = Substitute.For<IImageCache>();
|
||||
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
|
||||
.Returns(_ => LogoCachePath);
|
||||
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Watermark), Arg.Any<Option<int>>())
|
||||
.Returns(_ => CustomCachePath);
|
||||
|
||||
// Faithful to the real ImageCache.GetPathForImage, which does fileName[..2] and therefore THROWS on a
|
||||
// blank/null name. Modelling that is what makes the blank-image guard tests mutation-sensitive: before
|
||||
// #510 the channel and global arms had no guard and this threw out of stream startup.
|
||||
fakeImageCache
|
||||
.GetPathForImage(
|
||||
Arg.Is<string>(s => string.IsNullOrWhiteSpace(s)),
|
||||
Arg.Any<ArtworkKind>(),
|
||||
Arg.Any<Option<int>>())
|
||||
.Returns<string>(_ => throw new ArgumentOutOfRangeException(nameof(IImageCache.GetPathForImage)));
|
||||
|
||||
var decoSelector = Substitute.For<IDecoSelector>();
|
||||
decoSelector.GetDecoEntries(Arg.Any<Playout>(), Arg.Any<DateTimeOffset>())
|
||||
.Returns(new DecoEntries(Option<Deco>.None, Optional(playoutDeco)));
|
||||
|
||||
return new WatermarkSelector(
|
||||
mockFileSystem,
|
||||
fakeImageCache,
|
||||
decoSelector,
|
||||
NullLogger<WatermarkSelector>.Instance);
|
||||
}
|
||||
|
||||
private static ChannelWatermark Watermark(ChannelWatermarkImageSource source, string image = "") =>
|
||||
new()
|
||||
{
|
||||
Id = 7,
|
||||
Name = "Deco Bug",
|
||||
ImageSource = source,
|
||||
Image = image,
|
||||
Mode = ChannelWatermarkMode.Permanent
|
||||
};
|
||||
|
||||
private static Deco DecoWith(ChannelWatermark watermark) =>
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Test Deco",
|
||||
WatermarkMode = DecoMode.Override,
|
||||
UseWatermarkDuringFiller = true,
|
||||
DecoWatermarks = [new DecoWatermark { WatermarkId = watermark.Id, Watermark = watermark }],
|
||||
Watermarks = []
|
||||
};
|
||||
|
||||
private static Channel ChannelWith(string logoPath, ChannelWatermark channelWatermark = null)
|
||||
{
|
||||
var channel = new Channel(Guid.Empty)
|
||||
{
|
||||
Id = 1,
|
||||
Number = "1",
|
||||
Name = "Test",
|
||||
StreamingMode = StreamingMode.TransportStream,
|
||||
Artwork = [],
|
||||
Watermark = channelWatermark,
|
||||
WatermarkId = channelWatermark?.Id
|
||||
};
|
||||
|
||||
if (logoPath is not null)
|
||||
{
|
||||
channel.Artwork.Add(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = logoPath });
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
private static PlayoutItem PlayoutItem() =>
|
||||
new()
|
||||
{
|
||||
FillerKind = FillerKind.None,
|
||||
DisableWatermarks = false,
|
||||
Watermarks = [],
|
||||
Playout = new Playout()
|
||||
};
|
||||
|
||||
private static List<WatermarkOptions> SelectViaDeco(
|
||||
ChannelWatermark watermark,
|
||||
Channel channel,
|
||||
params string[] existingFiles)
|
||||
{
|
||||
WatermarkSelector selector = Selector(DecoWith(watermark), existingFiles);
|
||||
return selector.SelectWatermarks(
|
||||
Option<ChannelWatermark>.None,
|
||||
channel,
|
||||
PlayoutItem(),
|
||||
DateTimeOffset.Now);
|
||||
}
|
||||
|
||||
// ---- positive control: the arrangement CAN produce a watermark ------------------------------
|
||||
//
|
||||
// Without this, every "resolves to nothing" assertion below could pass vacuously (a broken deco
|
||||
// arrangement that never reaches the resolver at all looks identical to a correct refusal).
|
||||
|
||||
[Test]
|
||||
public void Deco_ChannelLogo_Should_Use_Cached_Path_When_Local_Logo_Exists()
|
||||
{
|
||||
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
|
||||
Channel channel = ChannelWith(LogoStoredPath);
|
||||
|
||||
List<WatermarkOptions> result = SelectViaDeco(watermark, channel, LogoCachePath);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].ImagePath.ShouldBe(LogoCachePath);
|
||||
}
|
||||
|
||||
// ---- ChannelLogo: the three cases #510 was filed for ----------------------------------------
|
||||
|
||||
[Test]
|
||||
public void Deco_ChannelLogo_Should_Be_Ignored_When_Logo_Is_An_External_Url()
|
||||
{
|
||||
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
|
||||
Channel channel = ChannelWith(ExternalLogoUrl);
|
||||
|
||||
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deco_ChannelLogo_Should_Be_Ignored_When_Local_Logo_File_Is_Missing()
|
||||
{
|
||||
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
|
||||
Channel channel = ChannelWith(LogoStoredPath);
|
||||
|
||||
// nothing on disk
|
||||
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The #510 policy decision: with no logo artwork the generated-initials fallback is NOT used. It
|
||||
/// genuinely rendered here before (confirmed by live-E2E on a real frame), so this is a deliberate,
|
||||
/// recorded behavior change — not a no-op cleanup.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Deco_ChannelLogo_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork()
|
||||
{
|
||||
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
|
||||
Channel channel = ChannelWith(null);
|
||||
|
||||
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
|
||||
// Folded in from a separate test that asserted only this. On its own it was vacuous — an empty list
|
||||
// trivially contains no URL — so it is a second assertion here rather than a test implying independent
|
||||
// coverage. It earns its place by naming the value if this ever starts returning options again (#652).
|
||||
result.Select(o => o.ImagePath)
|
||||
.ShouldNotContain(ChannelLogoGenerator.GenerateChannelLogoUrl(channel));
|
||||
}
|
||||
|
||||
// ---- Custom and Resource: the two arms #510 did not mention but that diverged too -----------
|
||||
|
||||
[Test]
|
||||
public void Deco_Custom_Should_Use_Cached_Path_When_File_Exists()
|
||||
{
|
||||
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
|
||||
Channel channel = ChannelWith(LogoStoredPath);
|
||||
|
||||
List<WatermarkOptions> result = SelectViaDeco(watermark, channel, CustomCachePath);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].ImagePath.ShouldBe(CustomCachePath);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deco_Custom_Should_Be_Ignored_When_File_Is_Missing()
|
||||
{
|
||||
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
|
||||
Channel channel = ChannelWith(LogoStoredPath);
|
||||
|
||||
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deco_Custom_Should_Be_Ignored_When_Image_Is_Blank()
|
||||
{
|
||||
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Custom, " ");
|
||||
Channel channel = ChannelWith(LogoStoredPath);
|
||||
|
||||
List<WatermarkOptions> result = SelectViaDeco(watermark, channel, CustomCachePath);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deco_Resource_Should_Use_Resource_Path_When_File_Exists()
|
||||
{
|
||||
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
|
||||
Channel channel = ChannelWith(LogoStoredPath);
|
||||
|
||||
List<WatermarkOptions> result = SelectViaDeco(watermark, channel, ResourcePath);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].ImagePath.ShouldBe(ResourcePath);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deco_Resource_Should_Be_Ignored_When_File_Is_Missing()
|
||||
{
|
||||
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
|
||||
Channel channel = ChannelWith(LogoStoredPath);
|
||||
|
||||
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- non-deco consequences of the SAME unification -------------------------------------------
|
||||
//
|
||||
// These pin precedence-level behavior rather than deco behavior, but they exist because of the #510
|
||||
// unification: one is the single piece of per-caller policy deliberately kept, the others are arms that
|
||||
// used to throw. Without them a future refactor can delete the survivor, or re-introduce the crash, with
|
||||
// a fully green suite.
|
||||
|
||||
/// <summary>
|
||||
/// The one surviving per-caller policy: a playout-item `Custom` watermark with a blank image falls
|
||||
/// THROUGH to the channel/global watermark rather than resolving to "no watermark". Unifying
|
||||
/// resolution must not change which watermark WINS.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_Channel_Watermark()
|
||||
{
|
||||
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, " ");
|
||||
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
|
||||
channelWatermark.Id = 8;
|
||||
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
|
||||
|
||||
Option<WatermarkOptions> result = Selector(null, CustomCachePath)
|
||||
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
|
||||
|
||||
// the CHANNEL watermark wins -- not None, and not the blank playout-item one
|
||||
result.IsSome.ShouldBeTrue();
|
||||
WatermarkOptions options = result.IfNone(() => throw new InvalidOperationException());
|
||||
options.ImagePath.ShouldBe(CustomCachePath);
|
||||
options.Watermark.Id.ShouldBe(8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Before #510 the channel and global arms had no blank-image guard, so they reached
|
||||
/// <c>ImageCache.GetPathForImage</c> whose <c>fileName[..2]</c> threw out of stream startup. Now a
|
||||
/// warning plus no watermark.
|
||||
/// </summary>
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Channel_Level_Blank_Custom_Watermark_Should_Resolve_To_None_Not_Throw(string image)
|
||||
{
|
||||
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.Custom, image);
|
||||
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
|
||||
|
||||
Option<WatermarkOptions> result = Should.NotThrow(
|
||||
() => Selector(null).GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None));
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same fall-through, but landing on the GLOBAL watermark — the channel-level variant above cannot
|
||||
/// distinguish "fell through correctly" from "stopped at the channel by accident".
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_Global_Watermark()
|
||||
{
|
||||
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, " ");
|
||||
ChannelWatermark globalWatermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
|
||||
globalWatermark.Id = 9;
|
||||
|
||||
// no channel-level watermark, so the only remaining candidate is the global one
|
||||
Channel channel = ChannelWith(LogoStoredPath);
|
||||
|
||||
Option<WatermarkOptions> result = Selector(null, CustomCachePath)
|
||||
.GetWatermarkOptions(channel, playoutItemWatermark, globalWatermark);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
WatermarkOptions options = result.IfNone(() => throw new InvalidOperationException());
|
||||
options.ImagePath.ShouldBe(CustomCachePath);
|
||||
options.Watermark.Id.ShouldBe(9);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The complement of the fall-through cases: a NON-blank custom image whose file is merely missing must
|
||||
/// NOT fall through — it resolves to "no watermark" and the channel watermark never gets a turn.
|
||||
/// Without this, widening the blank-image guard to "any unresolvable custom" would pass unnoticed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The channel-level fallback is deliberately an INDEPENDENTLY RESOLVABLE `ChannelLogo` watermark whose
|
||||
/// cached file exists. An earlier version of this test gave the fallback the same missing custom path as
|
||||
/// the playout-item watermark, which made it unfalsifiable: a wrongly-widened guard would have fallen
|
||||
/// through to a fallback that also resolved to None, so the assertion held either way.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Missing_But_Named_Custom_Playout_Item_Watermark_Should_Not_Fall_Through()
|
||||
{
|
||||
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
|
||||
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
|
||||
channelWatermark.Id = 8;
|
||||
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
|
||||
|
||||
// the channel logo's cached file EXISTS, so a fall-through would return it and fail this test;
|
||||
// the custom watermark's file does not, so the playout-item watermark is unresolvable
|
||||
Option<WatermarkOptions> result = Selector(null, LogoCachePath)
|
||||
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control for the test above: the same arrangement, but with the playout-item watermark BLANK
|
||||
/// rather than missing, must fall through and return the resolvable channel logo. Together the pair
|
||||
/// shows the guard distinguishes blank from unresolvable, rather than both landing on None.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parameterized over all three blank forms because the guard is <c>IsNullOrWhiteSpace</c>: testing only
|
||||
/// <c>" "</c> would let a mutation to <c>image == " "</c> pass while silently breaking fall-through
|
||||
/// for <c>null</c> and <c>""</c> — and <c>null</c> is the form the API actually persists.
|
||||
/// </remarks>
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_A_Resolvable_Channel_Logo(string image)
|
||||
{
|
||||
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, image);
|
||||
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
|
||||
channelWatermark.Id = 8;
|
||||
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
|
||||
|
||||
Option<WatermarkOptions> result = Selector(null, LogoCachePath)
|
||||
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LogoCachePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the <c>ImageSource is Custom</c> half of the blank-image guard, which nothing else covers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>ChannelLogo</c> watermark's <c>Image</c> is NORMALLY blank — the API persists `Image = null` for
|
||||
/// every non-`Custom` source — so if the guard's `is Custom` discriminator were dropped, leaving only
|
||||
/// `IsNullOrWhiteSpace(Image)`, every playout-item `ChannelLogo` watermark would fall through to
|
||||
/// channel/global instead of resolving the channel's own logo. This test fails on that mutation: the
|
||||
/// playout-item watermark carries a distinguishing Id, so falling through is observable even though both
|
||||
/// levels would resolve to the same cached path.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Blank_Image_ChannelLogo_Playout_Item_Watermark_Should_Win_And_Not_Fall_Through()
|
||||
{
|
||||
// Image is left blank, exactly as the API stores a ChannelLogo watermark
|
||||
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
|
||||
playoutItemWatermark.Id = 42;
|
||||
|
||||
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
|
||||
channelWatermark.Id = 8;
|
||||
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
|
||||
|
||||
Option<WatermarkOptions> result = Selector(null, LogoCachePath)
|
||||
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
WatermarkOptions options = result.IfNone(() => throw new InvalidOperationException());
|
||||
options.ImagePath.ShouldBe(LogoCachePath);
|
||||
|
||||
// the PLAYOUT-ITEM watermark won; a fall-through would have returned the channel's (Id 8)
|
||||
options.Watermark.Id.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// `CreateWatermarkHandler`/`UpdateWatermarkHandler` write `Image = null` for every non-`Custom`
|
||||
/// watermark, so an API-created `Resource` watermark hits `Path.Combine(folder, null)` — an
|
||||
/// `ArgumentNullException` out of stream startup. Uses the persisted shape (null), not a hand-made
|
||||
/// filename, which is what the rest of the fixture would otherwise assume.
|
||||
/// </summary>
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
public void Resource_Watermark_With_No_Image_Name_Should_Resolve_To_None_Not_Throw(string image)
|
||||
{
|
||||
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.Resource, image);
|
||||
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
|
||||
|
||||
Option<WatermarkOptions> result = Should.NotThrow(
|
||||
() => Selector(null).GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None));
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dropping an unresolvable watermark shortens the list handed to
|
||||
/// <c>CanUseFFmpegNativeWatermark</c>, whose predicate includes `Count == 1`. So this is also the pin on
|
||||
/// the observable routing change: two attached permanent watermarks, one missing, now yield ONE option
|
||||
/// (ffmpeg-native) where they previously yielded two (graphics engine).
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Deco_With_One_Valid_And_One_Missing_Watermark_Should_Return_Only_The_Valid_One()
|
||||
{
|
||||
ChannelWatermark valid = Watermark(ChannelWatermarkImageSource.ChannelLogo);
|
||||
ChannelWatermark missing = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
|
||||
missing.Id = 8;
|
||||
|
||||
var deco = new Deco
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Test Deco",
|
||||
WatermarkMode = DecoMode.Override,
|
||||
UseWatermarkDuringFiller = true,
|
||||
DecoWatermarks =
|
||||
[
|
||||
new DecoWatermark { WatermarkId = valid.Id, Watermark = valid },
|
||||
new DecoWatermark { WatermarkId = missing.Id, Watermark = missing }
|
||||
],
|
||||
Watermarks = []
|
||||
};
|
||||
|
||||
// only the channel logo's cached file exists; the custom watermark's does not
|
||||
List<WatermarkOptions> result = Selector(deco, LogoCachePath).SelectWatermarks(
|
||||
Option<ChannelWatermark>.None,
|
||||
ChannelWith(LogoStoredPath),
|
||||
PlayoutItem(),
|
||||
DateTimeOffset.Now);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].ImagePath.ShouldBe(LogoCachePath);
|
||||
|
||||
// The routing claim itself, not just the filtering: call the real predicate. Asserting Count == 1 alone
|
||||
// would leave the decision record's "now routes ffmpeg-native" statement unpinned, since the decision
|
||||
// lives in FFmpegLibraryProcessService rather than in the selector.
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, result).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Before #510 the global arm had no <c>Resource</c> case and hit <c>default: throw</c>.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Global_Level_Resource_Watermark_Should_Resolve_Instead_Of_Throwing()
|
||||
{
|
||||
ChannelWatermark globalWatermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
|
||||
Channel channel = ChannelWith(LogoStoredPath);
|
||||
|
||||
Option<WatermarkOptions> result = Should.NotThrow(
|
||||
() => Selector(null, ResourcePath).GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
globalWatermark));
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(ResourcePath);
|
||||
}
|
||||
|
||||
// ---- the structural guard: deco and channel-level must agree, case for case ------------------
|
||||
|
||||
private static IEnumerable<TestCaseData> ParityCases()
|
||||
{
|
||||
// (image source, watermark.Image, channel logo path, files that exist)
|
||||
yield return new TestCaseData(
|
||||
ChannelWatermarkImageSource.ChannelLogo, "", LogoStoredPath, new[] { LogoCachePath })
|
||||
.SetName("ChannelLogo, local file present");
|
||||
yield return new TestCaseData(
|
||||
ChannelWatermarkImageSource.ChannelLogo, "", LogoStoredPath, Array.Empty<string>())
|
||||
.SetName("ChannelLogo, local file missing");
|
||||
yield return new TestCaseData(
|
||||
ChannelWatermarkImageSource.ChannelLogo, "", ExternalLogoUrl, Array.Empty<string>())
|
||||
.SetName("ChannelLogo, external URL");
|
||||
yield return new TestCaseData(
|
||||
ChannelWatermarkImageSource.ChannelLogo, "", null, Array.Empty<string>())
|
||||
.SetName("ChannelLogo, no logo artwork");
|
||||
yield return new TestCaseData(
|
||||
ChannelWatermarkImageSource.Custom, CustomStoredPath, LogoStoredPath, new[] { CustomCachePath })
|
||||
.SetName("Custom, file present");
|
||||
yield return new TestCaseData(
|
||||
ChannelWatermarkImageSource.Custom, CustomStoredPath, LogoStoredPath, Array.Empty<string>())
|
||||
.SetName("Custom, file missing");
|
||||
// Both sides agree here by construction (each returns nothing), which is the point: it documents that
|
||||
// the blank-image fall-through asymmetry lives ONLY at the playout-item level -- covered by
|
||||
// Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_Channel_Watermark -- rather than leaving
|
||||
// the omission looking like an evasion.
|
||||
yield return new TestCaseData(
|
||||
ChannelWatermarkImageSource.Custom, " ", LogoStoredPath, Array.Empty<string>())
|
||||
.SetName("Custom, blank image");
|
||||
yield return new TestCaseData(
|
||||
ChannelWatermarkImageSource.Resource, ResourceImage, LogoStoredPath, new[] { ResourcePath })
|
||||
.SetName("Resource, file present");
|
||||
yield return new TestCaseData(
|
||||
ChannelWatermarkImageSource.Resource, ResourceImage, LogoStoredPath, Array.Empty<string>())
|
||||
.SetName("Resource, file missing");
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(ParityCases))]
|
||||
public void Deco_And_Channel_Level_Should_Resolve_Identically(
|
||||
ChannelWatermarkImageSource source,
|
||||
string image,
|
||||
string logoPath,
|
||||
string[] existingFiles)
|
||||
{
|
||||
// deco path
|
||||
ChannelWatermark decoWatermark = Watermark(source, image);
|
||||
List<WatermarkOptions> viaDeco = SelectViaDeco(decoWatermark, ChannelWith(logoPath), existingFiles);
|
||||
|
||||
// channel precedence level, same watermark definition and same channel
|
||||
ChannelWatermark channelWatermark = Watermark(source, image);
|
||||
Channel channel = ChannelWith(logoPath, channelWatermark);
|
||||
Option<WatermarkOptions> viaChannel = Selector(null, existingFiles)
|
||||
.GetWatermarkOptions(channel, Option<ChannelWatermark>.None, Option<ChannelWatermark>.None);
|
||||
|
||||
List<string> decoPaths = viaDeco.Select(o => o.ImagePath).ToList();
|
||||
|
||||
// built explicitly rather than via Option.ToList(), which yields a LanguageExt Lst<string>
|
||||
var channelPaths = new List<string>();
|
||||
viaChannel.IfSome(o => channelPaths.Add(o.ImagePath));
|
||||
|
||||
decoPaths.ShouldBe(channelPaths);
|
||||
}
|
||||
}
|
||||
@@ -171,125 +171,136 @@ public class WatermarkSelector(
|
||||
// check for playout item watermark
|
||||
foreach (ChannelWatermark watermark in playoutItemWatermark)
|
||||
{
|
||||
switch (watermark.ImageSource)
|
||||
// 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))
|
||||
{
|
||||
// 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");
|
||||
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)
|
||||
{
|
||||
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");
|
||||
}
|
||||
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)
|
||||
{
|
||||
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");
|
||||
}
|
||||
logger.LogDebug("Watermark will come from global ({ImageSource})", watermark.ImageSource);
|
||||
return ResolveWatermark(channel, watermark);
|
||||
}
|
||||
|
||||
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.
|
||||
/// 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
|
||||
@@ -324,79 +335,52 @@ public class WatermarkSelector(
|
||||
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.
|
||||
// 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 logo no longer exists at {Path} and will be ignored",
|
||||
"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(GetWatermarkOptions(channel, watermark));
|
||||
result.AddRange(ResolveWatermark(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;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -201,8 +201,27 @@ with no bug until you re-save it. Historical context (the old `File.Exists`-on-a
|
||||
bounded render-time fetch that preceded caching) is in `docs/decisions.md` under
|
||||
`graphics.channel-logo-caching`, #502 and #511.
|
||||
|
||||
Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo
|
||||
fetching — see issue #1 for details.
|
||||
**No usable logo means no on-screen bug — from every attachment point (#510).** A `ChannelLogo`
|
||||
watermark resolves through one shared resolver (`WatermarkSelector.ResolveWatermark`) whether it is
|
||||
attached via a playout item, the channel, the global setting, **or a deco**. All four agree: an
|
||||
un-migrated external URL, a missing cached file, and a channel with no logo artwork each render
|
||||
*without* a bug and log a warning. The selector never hands a dead path or a URL downstream — a dead
|
||||
local path could otherwise reach ffmpeg as a bare `-i` argument and break the stream, which is worse
|
||||
than a skipped overlay. (One watermark is built *outside* the selector and is still unchecked: the
|
||||
song-progress overlay — see #653.)
|
||||
|
||||
Before #510 the deco path had its own unchecked copy of that resolution, so the same channel could
|
||||
disagree with itself about whether a bug rendered based only on how the watermark was attached. The
|
||||
divergence covered `Custom` and `Resource` image sources too, not just `ChannelLogo`.
|
||||
|
||||
That change switched off one thing that *did* work: a channel with **no** logo artwork used to get a
|
||||
generated-initials nameplate (`/iptv/logos/gen`, drawn by `ChannelLogoGenerator`) when — and only
|
||||
when — the watermark came from a deco. It is now off everywhere, because serving it means an HTTP
|
||||
fetch inside stream startup, exactly what `graphics.channel-logo-caching` (#525) removed for logos,
|
||||
and because `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` (issue #1, closed
|
||||
as a topology problem without removing the hardcode). Reviving it properly means generating the image
|
||||
into the image cache so it resolves to a local path — tracked as **#652**; the rationale is in
|
||||
`ffmpeg.watermark-resolution-unified`.
|
||||
|
||||
## On Now / Next overlay (#74)
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `ffmpeg.qsv-decode-encode-split` | QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. | 2026-07-20 | [link](records/ffmpeg/qsv-decode-encode-split.md) |
|
||||
| `ffmpeg.qsv-extra-hw-frames-floor` | a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all. | 2026-07-21 | [link](records/ffmpeg/qsv-extra-hw-frames-floor.md) |
|
||||
| `ffmpeg.remote-image-fetcher-bounded` | remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. | 2026-07-20 | [link](records/ffmpeg/remote-image-fetcher-bounded.md) |
|
||||
| `ffmpeg.watermark-resolution-unified` | Every watermark `WatermarkSelector` resolves goes through one shared `ResolveWatermark` — the playout-item, channel and global precedence levels AND the deco path, for all three `ChannelWatermarkImageSource` values. An unresolvable watermark (missing file, un-migrated external URL, or no logo artwork) resolves to no on-screen bug plus a warning, never a dead path or a URL handed downstream; the one deliberate exception is a playout-item `Custom` with a blank image, which still falls THROUGH to channel/global. The generated-initials fallback is therefore off everywhere, including the deco path where it demonstrably rendered. Watermarks built OUTSIDE the selector (the song-progress overlay, #653) are not covered and remain unchecked. | 2026-07-26 | [link](records/ffmpeg/watermark-resolution-unified.md) |
|
||||
| `ffmpeg.work-ahead-slot-atomic` | `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another. | 2026-07-21 | [link](records/ffmpeg/work-ahead-slot-atomic.md) |
|
||||
| `ffmpeg.work-ahead-slot-release-never-negative` | `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return. | 2026-07-21 | [link](records/ffmpeg/work-ahead-slot-release-never-negative.md) |
|
||||
| `graphics.channel-level-attachment` | A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box. | 2026-07-22 | [link](records/graphics/channel-level-attachment.md) |
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
key: ffmpeg.watermark-resolution-unified
|
||||
title: 2026-07-26 — One watermark resolver for all four attachment points; no usable logo means no bug (#510)
|
||||
status: active
|
||||
since: '2026-07-26'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'Every watermark `WatermarkSelector` resolves goes through one shared `ResolveWatermark` — the playout-item, channel and global precedence levels AND the deco path, for all three `ChannelWatermarkImageSource` values. An unresolvable watermark (missing file, un-migrated external URL, or no logo artwork) resolves to no on-screen bug plus a warning, never a dead path or a URL handed downstream; the one deliberate exception is a playout-item `Custom` with a blank image, which still falls THROUGH to channel/global. The generated-initials fallback is therefore off everywhere, including the deco path where it demonstrably rendered. Watermarks built OUTSIDE the selector (the song-progress overlay, #653) are not covered and remain unchecked.'
|
||||
signals: 'deco watermark, missing logo policy, generated initials, nameplate bug, watermark resolution divergence, song-progress overlay bypass · paths: `ErsatzTV.Core/FFmpeg/WatermarkSelector.cs` (`ResolveWatermark`, `ChannelLogoWatermarkOptions`, `OptionsForWatermarks`), `ChannelLogoGenerator.GenerateChannelLogoUrl` · issues: #510, #502, #525, #1, #67, #652, #653'
|
||||
mechanics: '`WatermarkSelectorDecoResolutionTests` (incl. the `Deco_And_Channel_Level_Should_Resolve_Identically` parity cases); `docs/channels.md` → Watermarks'
|
||||
---
|
||||
|
||||
`WatermarkSelector` resolved a watermark in two places with two policies. The **deco** path had its own
|
||||
copy of the image-source switch that returned whatever path it computed, unchecked. The precedence
|
||||
levels checked — though not uniformly: the playout-item level checked all three sources, while the
|
||||
channel and global levels checked `Custom`/`ChannelLogo` and *threw* for `Resource` (they had no arm
|
||||
for it, so it fell to `default:`). So one channel could disagree with itself about whether a bug
|
||||
rendered, based only on how the watermark was attached.
|
||||
|
||||
#502 had noted the split and deferred it here. What #502's note got wrong is scope: the divergence was
|
||||
never `ChannelLogo`-only. The deco path skipped the existence check for **`Custom` and `Resource`
|
||||
too** — three arms, not one. Fixing the named arm and leaving two is how this defect reached
|
||||
triplicate in the first place, so the selector now has exactly **one** resolver. One piece of
|
||||
per-caller policy survives by design, and it lives in the *caller* rather than the resolver: the
|
||||
playout-item blank-`Custom` fall-through described below.
|
||||
|
||||
**Scope boundary, found by review of this very change:** "one resolver" is true *of the selector*, not
|
||||
of the application. `GetPlayoutItemProcessByChannelNumberHandler` and
|
||||
`PrepareTroubleshootingPlaybackHandler` build the song-progress overlay's `WatermarkOptions` by hand
|
||||
with an unchecked `Path.Combine(ResourcesCacheFolder, …)` and then `watermarks.Clear()`, so they
|
||||
bypass `ResolveWatermark` and can still hand ffmpeg a nonexistent `-i` (the exact hazard described
|
||||
below). Pre-existing and left alone here; tracked as **#653**. An earlier draft of this record claimed
|
||||
the hazard was closed outright — it is closed only for watermarks the selector resolves.
|
||||
|
||||
**Severity is not merely cosmetic, which is what settled the direction.** A dead *local* path is not
|
||||
harmlessly skipped: `CanUseFFmpegNativeWatermark` routes a single permanent watermark to ffmpeg as a
|
||||
bare `-i` argument, and only *URLs* are excluded from that shortcut. So the deco path could hand
|
||||
ffmpeg a nonexistent input file. A URL, by contrast, reaches the graphics engine, whose fetch failures
|
||||
are caught into "overlay disabled". Unchecked resolution was the more dangerous of the two policies.
|
||||
|
||||
**The generated-initials fallback was real, and is deliberately switched off.** With no logo artwork
|
||||
the deco path returned `ChannelLogoGenerator.GenerateChannelLogoUrl` — a `localhost` URL. A live-E2E
|
||||
on a real transcoded frame (deco in `Override` mode, `ChannelLogo` watermark, channel with no logo
|
||||
artwork) confirmed the nameplate **did** composite: `/iptv/logos/gen` sits on `ArtworkController`,
|
||||
which carries no auth filter (unlike `IptvController`), so the container-internal self-fetch
|
||||
succeeded. This is recorded because the #502-era comment asserted the opposite ("it has never
|
||||
rendered here") and a future reader would otherwise re-inherit that error.
|
||||
|
||||
It is still removed, because keeping it means an HTTP fetch inside stream startup per element init —
|
||||
precisely what `graphics.channel-logo-caching` (#525) eliminated for logos — and it depends on #1's
|
||||
hardcoded `localhost`, which #1 closed as a topology problem without removing. Keeping it *only* on
|
||||
the deco path would preserve the exact incoherence this record exists to remove.
|
||||
|
||||
**Prod blast radius was measured, not assumed:** 0 `Deco` rows, 0 `DecoWatermark` rows, and all 43
|
||||
channels have logo artwork — so no rendered output changes. That measurement is what made "no bug"
|
||||
affordable over the two alternatives (enable initials everywhere; or cache the initials into the image
|
||||
cache and then enable). The second alternative is the right end-state and is **rejected only on
|
||||
scope**, not on merit: generating the image into the image cache would make it a local path, honouring
|
||||
#525 and sidestepping #1 entirely. That work is **#652** — anyone reviving the nameplate should do it
|
||||
that way rather than by reinstating a render-time URL.
|
||||
|
||||
**Preserved deliberately:** a playout-item `Custom` watermark with a blank image still falls *through*
|
||||
to the channel/global watermark instead of resolving to "no watermark". Unifying resolution must not
|
||||
change which watermark *wins*.
|
||||
|
||||
**Routing: the predicate is unchanged, its input is not.** `CanUseFFmpegNativeWatermark` still keys off
|
||||
the resolved path alone, so any URL still goes to the graphics engine regardless of provenance. But it
|
||||
also tests `watermarks.Count == 1`, and dropping an unresolvable watermark shortens that list — so a
|
||||
deco carrying one valid and one missing permanent watermark now routes ffmpeg-native (count 1) where it
|
||||
previously routed to the graphics engine (count 2). Intended, since what survives is a single valid
|
||||
permanent local image, but it is an observable routing change and an earlier draft of this record wrongly
|
||||
called routing untouched. Pinned by `Deco_With_One_Valid_And_One_Missing_Watermark_Should_Return_Only_The_Valid_One`.
|
||||
|
||||
**Three strict improvements, all previously crash-shaped.** The channel and global arms threw
|
||||
`NotSupportedException` on a `Resource` watermark; they now resolve it like the playout-item arm — and
|
||||
the `Resource` arm gained a blank/null guard it never had, because `CreateWatermarkHandler` and
|
||||
`UpdateWatermarkHandler` both write `Image = null` for every non-`Custom` watermark, so an
|
||||
API-created `Resource` watermark reached `Path.Combine(folder, null)` and threw `ArgumentNullException`
|
||||
at *any* level, including the playout-item one. Less
|
||||
obviously, those two arms also had **no blank-image guard** on `Custom` — only the playout-item arm
|
||||
did — so they called `ImageCache.GetPathForImage`, whose `fileName[..2]` throws
|
||||
`ArgumentOutOfRangeException` on `""` and `NullReferenceException` on `null`. That state is reachable
|
||||
through the current API, not just legacy rows: `CreateWatermarkHandler`/`UpdateWatermarkHandler`
|
||||
assign `Image = update.Image?.Path` with nothing requiring an image. So a channel-level `Custom`
|
||||
watermark saved with its image cleared threw *out of the selector during stream startup* — killing
|
||||
playback, not just the overlay — and now degrades to a warning plus no watermark. No existing test
|
||||
could see this, because every fixture stubs `IImageCache` and never executes `fileName[..2]`.
|
||||
The `default:` arm still throws, so a *newly added* image source fails loudly rather than inheriting
|
||||
a neighbour's behavior.
|
||||
|
||||
The parity test cases are the structural guard: they assert deco and channel-level resolution return
|
||||
identical paths case for case, so re-introducing a per-caller policy fails in CI rather than in prod.
|
||||
|
||||
**Measured mutation sensitivity: 19 of the 32 cases fail when the pre-fix resolver is restored under the
|
||||
final fixture.** The other 13 pass both ways by design — they pin behavior this change deliberately
|
||||
*preserves* (the blank-`Custom` fall-through, file-present resolution, the `is Custom` discriminator)
|
||||
plus the positive control, so passing before and after is the correct outcome for them, not a gap.
|
||||
|
||||
Two guard mutations were run individually, because a whole-file revert cannot show that a test aimed at
|
||||
a specific clause actually hits it:
|
||||
|
||||
| Mutation | Caught by |
|
||||
|---|---|
|
||||
| drop `ImageSource is Custom` from the blank guard | `Blank_Image_ChannelLogo_Playout_Item_Watermark_Should_Win_And_Not_Fall_Through` (1 failure, and the only test that catches it) |
|
||||
| narrow `IsNullOrWhiteSpace` to `== " "` | the `null` and `""` cases of `Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_A_Resolvable_Channel_Logo` |
|
||||
|
||||
That first mutation matters more than it looks: a `ChannelLogo` watermark's `Image` is *normally* blank,
|
||||
so dropping the discriminator would send every playout-item `ChannelLogo` watermark down the
|
||||
fall-through path instead of resolving the channel's own logo.
|
||||
|
||||
Re-measure rather than trust these figures if the fixture changes. Review of this very change found
|
||||
**two tests that had been added to close earlier review findings yet could not fail** — one whose
|
||||
fall-through fallback was itself unresolvable (so a wrongly-widened guard still yielded `None`), and one
|
||||
that asserted a filtered list length while the routing claim it was cited for lived in a function it
|
||||
never called. The lesson is that a test added under review pressure needs the same negative control as
|
||||
the original, and that a test's *name* is not evidence it pins what it claims.
|
||||
Reference in New Issue
Block a user