diff --git a/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorDecoResolutionTests.cs b/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorDecoResolutionTests.cs new file mode 100644 index 000000000..8ae3e001d --- /dev/null +++ b/ErsatzTV.Core.Tests/FFmpeg/WatermarkSelectorDecoResolutionTests.cs @@ -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; + +/// +/// Pins ersatztv#510: a watermark attached through a DECO resolves by exactly the same policy as the three +/// precedence levels (playout item, channel, global). +/// +/// +/// 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 values, not just +/// ChannelLogo: +/// +/// a missing local file was handed downstream as a dead path (and a dead LOCAL path can reach +/// ffmpeg as a bare -i argument via CanUseFFmpegNativeWatermark, so it is worse than a +/// skipped overlay); +/// an un-migrated external-URL logo was handed down as a renderable URL, which +/// graphics.channel-logo-caching (#525) forbids the render path from fetching; +/// 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. +/// +/// The Deco_And_Channel_Level_Should_Resolve_Identically 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. +/// +[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); + + /// Builds a selector whose mock filesystem contains exactly . + 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(); + fakeImageCache.GetPathForImage(Arg.Any(), Arg.Is(ArtworkKind.Logo), Arg.Any>()) + .Returns(_ => LogoCachePath); + fakeImageCache.GetPathForImage(Arg.Any(), Arg.Is(ArtworkKind.Watermark), Arg.Any>()) + .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(s => string.IsNullOrWhiteSpace(s)), + Arg.Any(), + Arg.Any>()) + .Returns(_ => throw new ArgumentOutOfRangeException(nameof(IImageCache.GetPathForImage))); + + var decoSelector = Substitute.For(); + decoSelector.GetDecoEntries(Arg.Any(), Arg.Any()) + .Returns(new DecoEntries(Option.None, Optional(playoutDeco))); + + return new WatermarkSelector( + mockFileSystem, + fakeImageCache, + decoSelector, + NullLogger.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 SelectViaDeco( + ChannelWatermark watermark, + Channel channel, + params string[] existingFiles) + { + WatermarkSelector selector = Selector(DecoWith(watermark), existingFiles); + return selector.SelectWatermarks( + Option.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 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 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 result = SelectViaDeco(watermark, channel); + + result.ShouldBeEmpty(); + } + + /// + /// 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. + /// + [Test] + public void Deco_ChannelLogo_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork() + { + ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo); + Channel channel = ChannelWith(null); + + List 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 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 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 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 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 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. + + /// + /// 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. + /// + [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 result = Selector(null, CustomCachePath) + .GetWatermarkOptions(channel, playoutItemWatermark, Option.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); + } + + /// + /// Before #510 the channel and global arms had no blank-image guard, so they reached + /// ImageCache.GetPathForImage whose fileName[..2] threw out of stream startup. Now a + /// warning plus no watermark. + /// + [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 result = Should.NotThrow( + () => Selector(null).GetWatermarkOptions( + channel, + Option.None, + Option.None)); + + result.IsNone.ShouldBeTrue(); + } + + /// + /// 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". + /// + [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 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); + } + + /// + /// 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. + /// + /// + /// 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. + /// + [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 result = Selector(null, LogoCachePath) + .GetWatermarkOptions(channel, playoutItemWatermark, Option.None); + + result.IsNone.ShouldBeTrue(); + } + + /// + /// 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. + /// + /// + /// Parameterized over all three blank forms because the guard is IsNullOrWhiteSpace: testing only + /// " " would let a mutation to image == " " pass while silently breaking fall-through + /// for null and "" — and null is the form the API actually persists. + /// + [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 result = Selector(null, LogoCachePath) + .GetWatermarkOptions(channel, playoutItemWatermark, Option.None); + + result.IsSome.ShouldBeTrue(); + result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LogoCachePath); + } + + /// + /// Pins the ImageSource is Custom half of the blank-image guard, which nothing else covers. + /// + /// + /// A ChannelLogo watermark's Image 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. + /// + [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 result = Selector(null, LogoCachePath) + .GetWatermarkOptions(channel, playoutItemWatermark, Option.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); + } + + /// + /// `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. + /// + [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 result = Should.NotThrow( + () => Selector(null).GetWatermarkOptions( + channel, + Option.None, + Option.None)); + + result.IsNone.ShouldBeTrue(); + } + + /// + /// Dropping an unresolvable watermark shortens the list handed to + /// CanUseFFmpegNativeWatermark, 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). + /// + [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 result = Selector(deco, LogoCachePath).SelectWatermarks( + Option.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(); + } + + /// + /// Before #510 the global arm had no Resource case and hit default: throw. + /// + [Test] + public void Global_Level_Resource_Watermark_Should_Resolve_Instead_Of_Throwing() + { + ChannelWatermark globalWatermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage); + Channel channel = ChannelWith(LogoStoredPath); + + Option result = Should.NotThrow( + () => Selector(null, ResourcePath).GetWatermarkOptions( + channel, + Option.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 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()) + .SetName("ChannelLogo, local file missing"); + yield return new TestCaseData( + ChannelWatermarkImageSource.ChannelLogo, "", ExternalLogoUrl, Array.Empty()) + .SetName("ChannelLogo, external URL"); + yield return new TestCaseData( + ChannelWatermarkImageSource.ChannelLogo, "", null, Array.Empty()) + .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()) + .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()) + .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()) + .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 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 viaChannel = Selector(null, existingFiles) + .GetWatermarkOptions(channel, Option.None, Option.None); + + List decoPaths = viaDeco.Select(o => o.ImagePath).ToList(); + + // built explicitly rather than via Option.ToList(), which yields a LanguageExt Lst + var channelPaths = new List(); + viaChannel.IfSome(o => channelPaths.Add(o.ImagePath)); + + decoPaths.ShouldBe(channelPaths); + } +} diff --git a/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs b/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs index 9db8a1c02..d547e742b 100644 --- a/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs +++ b/ErsatzTV.Core/FFmpeg/WatermarkSelector.cs @@ -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.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.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.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.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.None; } /// - /// Resolves a watermark to a renderable path, - /// shared by the playout-item, channel and global precedence levels so all three agree. + /// The single place a becomes a renderable image path, shared by every + /// watermark source: the three precedence levels (playout item, channel, global) AND the deco path. + /// + /// + /// 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 + /// Custom/ChannelLogo and *threw* for Resource (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 Custom watermark with a blank image falls through to channel/global + /// (see ). 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 -i argument + /// via CanUseFFmpegNativeWatermark, which is materially worse than a skipped overlay. + /// + /// Watermarks built OUTSIDE this selector are not covered — the song-progress overlay is constructed as a + /// WatermarkOptions directly by the streaming and troubleshooting handlers and is still unchecked + /// (#653). + /// + /// + private Option 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.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.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"); + } + } + + /// + /// Resolves a watermark to a renderable path. + /// Since #510 this is reached from , so all FOUR sources — the playout-item, + /// channel and global precedence levels AND the deco path — agree. /// /// /// 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; } + /// + /// Resolves the watermarks attached to a deco. Since #510 this shares + /// with the three precedence levels rather than carrying its own unchecked copy of the same switch. + /// + /// + /// 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 + /// ). + /// + /// The routing PREDICATE is unchanged — CanUseFFmpegNativeWatermark 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. + /// + /// private List OptionsForWatermarks(Channel channel, IEnumerable watermarks) { var result = new List(); foreach (var watermark in watermarks) { - result.AddRange(GetWatermarkOptions(channel, watermark)); + result.AddRange(ResolveWatermark(channel, watermark)); } return result; } - - private Option 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.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.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 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.None); - } - - return new WatermarkOptions(watermark, channelPath, None); - default: - throw new NotSupportedException("Unsupported watermark image source"); - } - - return Option.None; - } } diff --git a/docs/channels.md b/docs/channels.md index f5c813b81..cb69dba08 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -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) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index e7cb319a6..686aaca7d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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) | diff --git a/docs/decisions/records/ffmpeg/watermark-resolution-unified.md b/docs/decisions/records/ffmpeg/watermark-resolution-unified.md new file mode 100644 index 000000000..8732c32e6 --- /dev/null +++ b/docs/decisions/records/ffmpeg/watermark-resolution-unified.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.