From 43e1cbd9198e552ab35710e9ed2c876b2abe8d9b Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 14 Jul 2025 03:25:20 +0000 Subject: [PATCH] yaml playout watermarks (#2149) --- CHANGELOG.md | 6 +++ .../Scheduling/FillerExpressionTests.cs | 33 ++++++++++++++++ .../Repositories/IChannelRepository.cs | 1 + .../Handlers/YamlPlayoutAllHandler.cs | 7 +++- .../Handlers/YamlPlayoutCountHandler.cs | 7 +++- .../Handlers/YamlPlayoutDurationHandler.cs | 11 +++++- .../Handlers/YamlPlayoutPadToNextHandler.cs | 1 + .../Handlers/YamlPlayoutPadUntilHandler.cs | 1 + .../Handlers/YamlPlayoutWatermarkHandler.cs | 39 +++++++++++++++++++ .../Models/YamlPlayoutInstruction.cs | 3 ++ .../Models/YamlPlayoutWatermarkInstruction.cs | 11 ++++++ .../YamlScheduling/YamlPlayoutBuilder.cs | 5 ++- .../YamlScheduling/YamlPlayoutContext.cs | 13 +++++++ .../Data/Repositories/ChannelRepository.cs | 13 +++++++ 14 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs create mode 100644 ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutWatermarkInstruction.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index fd04b38e..6a745038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `count / 2` will play half of the items in the content - `random % 4 + 1` will play between 1 and 4 items - `2` (similar to before this change) will play exactly two items +- YAML playout: add `disable_watermarks` property to all content instructions + - This property defaults to `false` (meaning watermarks are allowed by default) + - Setting to `true` will prevent watermarks from ever appearing over the content +- YAML playout: add `watermark` instruction + - With value of `true` and `name` property, will override the watermark in the playout to the watermark with the provided name + - With value of `false`, will restore default watermark value (channel watermark, global watermark) - Show health check warning and error badges in nav menu - Add `Expression` for mid-roll filler to allow custom logic for using or skipping chapter markers - The following parameters can be used: diff --git a/ErsatzTV.Core.Tests/Scheduling/FillerExpressionTests.cs b/ErsatzTV.Core.Tests/Scheduling/FillerExpressionTests.cs index f432e570..71ad414b 100644 --- a/ErsatzTV.Core.Tests/Scheduling/FillerExpressionTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/FillerExpressionTests.cs @@ -41,4 +41,37 @@ public class FillerExpressionTests result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20)); result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30)); } + + [Test] + public void Two_Points_In_30_Minute_Content_Another_Expression() + { + // 30 min content + var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime }; + playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30); + + // chapters every 5 min + var chapters = new List + { + new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5) }, + new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10) }, + new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15) }, + new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20) }, + new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25) }, + new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30) } + }; + + // skip first 5 min of content, wait at least 5 min between points, only match up to 2 points + var fillerPreset = new FillerPreset + { + FillerKind = FillerKind.MidRoll, + Expression = "(total_progress >= 0.2 and matched_points = 0) or (total_progress >= 0.6 and matched_points = 1)" + }; + + List result = FillerExpression.FilterChapters(fillerPreset, chapters, playoutItem); + + result.Count.ShouldBe(3); + result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10)); + result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20)); + result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30)); + } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs index ee20953c..2b07779e 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs @@ -7,4 +7,5 @@ public interface IChannelRepository Task> GetChannel(int id); Task> GetByNumber(string number); Task> GetAll(); + Task> GetWatermarkByName(string name); } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs index 80af080b..bf8435b6 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs @@ -44,7 +44,7 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou OutPoint = itemDuration, FillerKind = GetFillerKind(all), CustomTitle = string.IsNullOrWhiteSpace(all.CustomTitle) ? null : all.CustomTitle, - //WatermarkId = scheduleItem.WatermarkId, + DisableWatermarks = all.DisableWatermarks, //PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, //PreferredAudioTitle = scheduleItem.PreferredAudioTitle, //PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, @@ -57,6 +57,11 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou //CollectionEtag = collectionEtags[collectionKey] }; + foreach (int watermarkId in context.GetChannelWatermarkId()) + { + playoutItem.WatermarkId = watermarkId; + } + context.Playout.Items.Add(playoutItem); context.AdvanceGuideGroup(); diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs index c4aebc93..316b1e5b 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs @@ -69,7 +69,7 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay OutPoint = itemDuration, FillerKind = GetFillerKind(count), CustomTitle = string.IsNullOrWhiteSpace(count.CustomTitle) ? null : count.CustomTitle, - //WatermarkId = scheduleItem.WatermarkId, + DisableWatermarks = count.DisableWatermarks, //PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, //PreferredAudioTitle = scheduleItem.PreferredAudioTitle, //PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, @@ -82,6 +82,11 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay //CollectionEtag = collectionEtags[collectionKey] }; + foreach (int watermarkId in context.GetChannelWatermarkId()) + { + playoutItem.WatermarkId = watermarkId; + } + context.Playout.Items.Add(playoutItem); context.AdvanceGuideGroup(); diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs index 4128ec3b..8a96403e 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs @@ -61,6 +61,7 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP duration.OfflineTail, GetFillerKind(duration), duration.CustomTitle, + duration.DisableWatermarks, enumerator, fallbackEnumerator, logger); @@ -82,6 +83,7 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP bool offlineTail, FillerKind fillerKind, string customTitle, + bool disableWatermarks, IMediaCollectionEnumerator enumerator, Option fallbackEnumerator, ILogger logger) @@ -103,10 +105,15 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP OutPoint = itemDuration, GuideGroup = context.PeekNextGuideGroup(), FillerKind = fillerKind, - CustomTitle = string.IsNullOrWhiteSpace(customTitle) ? null : customTitle - //DisableWatermarks = !allowWatermarks + CustomTitle = string.IsNullOrWhiteSpace(customTitle) ? null : customTitle, + DisableWatermarks = disableWatermarks }; + foreach (int watermarkId in context.GetChannelWatermarkId()) + { + playoutItem.WatermarkId = watermarkId; + } + if (remainingToFill - itemDuration >= TimeSpan.Zero || !stopBeforeEnd) { context.Playout.Items.Add(playoutItem); diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadToNextHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadToNextHandler.cs index 2f71af2f..b111d757 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadToNextHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadToNextHandler.cs @@ -65,6 +65,7 @@ public class YamlPlayoutPadToNextHandler(EnumeratorCache enumeratorCache) : Yaml true, GetFillerKind(padToNext), padToNext.CustomTitle, + padToNext.DisableWatermarks, enumerator, fallbackEnumerator, logger); diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadUntilHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadUntilHandler.cs index e3e88f9c..04cae5ca 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadUntilHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadUntilHandler.cs @@ -84,6 +84,7 @@ public class YamlPlayoutPadUntilHandler(EnumeratorCache enumeratorCache) : YamlP padUntil.OfflineTail, GetFillerKind(padUntil), padUntil.CustomTitle, + padUntil.DisableWatermarks, enumerator, fallbackEnumerator, logger); diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs new file mode 100644 index 00000000..dabe6e11 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs @@ -0,0 +1,39 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Scheduling.YamlScheduling.Models; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; + +public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) : IYamlPlayoutHandler +{ + public bool Reset => false; + + public async Task Handle( + YamlPlayoutContext context, + YamlPlayoutInstruction instruction, + PlayoutBuildMode mode, + ILogger logger, + CancellationToken cancellationToken) + { + if (instruction is not YamlPlayoutWatermarkInstruction watermark) + { + return false; + } + + if (watermark.Watermark && !string.IsNullOrWhiteSpace(watermark.Name)) + { + Option maybeWatermark = await channelRepository.GetWatermarkByName(watermark.Name); + foreach (ChannelWatermark channelWatermark in maybeWatermark) + { + context.SetChannelWatermarkId(channelWatermark.Id); + } + } + else + { + context.ClearChannelWatermarkId(); + } + + return true; + } +} diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs index aad02572..0ef4aed1 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs @@ -14,6 +14,9 @@ public class YamlPlayoutInstruction [YamlMember(Alias = "custom_title", ApplyNamingConventions = false)] public string CustomTitle { get; set; } + [YamlMember(Alias = "disable_watermarks", ApplyNamingConventions = false)] + public bool DisableWatermarks { get; set; } = false; + [YamlIgnore] public string SequenceKey { get; set; } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutWatermarkInstruction.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutWatermarkInstruction.cs new file mode 100644 index 00000000..f93c79b2 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutWatermarkInstruction.cs @@ -0,0 +1,11 @@ +using YamlDotNet.Serialization; + +namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models; + +public class YamlPlayoutWatermarkInstruction : YamlPlayoutInstruction +{ + [YamlMember(Alias = "watermark", ApplyNamingConventions = false)] + public bool Watermark { get; set; } + + public string Name { get; set; } +} diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs index 9e0dc3a3..f5bbbee8 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs @@ -17,6 +17,7 @@ public class YamlPlayoutBuilder( ILocalFileSystem localFileSystem, IConfigElementRepository configElementRepository, IMediaCollectionRepository mediaCollectionRepository, + IChannelRepository channelRepository, ILogger logger) : IYamlPlayoutBuilder { @@ -274,7 +275,7 @@ public class YamlPlayoutBuilder( } } - private static Option GetHandlerForInstruction( + private Option GetHandlerForInstruction( Dictionary handlers, EnumeratorCache enumeratorCache, YamlPlayoutInstruction instruction) @@ -289,6 +290,7 @@ public class YamlPlayoutBuilder( YamlPlayoutRepeatInstruction => new YamlPlayoutRepeatHandler(), YamlPlayoutWaitUntilInstruction => new YamlPlayoutWaitUntilHandler(), YamlPlayoutEpgGroupInstruction => new YamlPlayoutEpgGroupHandler(), + YamlPlayoutWatermarkInstruction => new YamlPlayoutWatermarkHandler(channelRepository), YamlPlayoutShuffleSequenceInstruction => new YamlPlayoutShuffleSequenceHandler(), YamlPlayoutSkipItemsInstruction => new YamlPlayoutSkipItemsHandler(enumeratorCache), @@ -341,6 +343,7 @@ public class YamlPlayoutBuilder( { "count", typeof(YamlPlayoutCountInstruction) }, { "duration", typeof(YamlPlayoutDurationInstruction) }, { "epg_group", typeof(YamlPlayoutEpgGroupInstruction) }, + { "watermark", typeof(YamlPlayoutWatermarkInstruction) }, { "pad_to_next", typeof(YamlPlayoutPadToNextInstruction) }, { "pad_until", typeof(YamlPlayoutPadUntilInstruction) }, { "repeat", typeof(YamlPlayoutRepeatInstruction) }, diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs index bb672ff2..abc22a5a 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs @@ -9,6 +9,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio private int _guideGroup = guideGroup; private bool _guideGroupLocked; private int _instructionIndex; + private Option _channelWatermarkId; public Playout Playout { get; } = playout; @@ -69,4 +70,16 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio } public void UnlockGuideGroup() => _guideGroupLocked = false; + + public void SetChannelWatermarkId(int id) + { + _channelWatermarkId = id; + } + + public void ClearChannelWatermarkId() + { + _channelWatermarkId = Option.None; + } + + public Option GetChannelWatermarkId() => _channelWatermarkId; } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index 40924746..2c4401ab 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -44,4 +44,17 @@ public class ChannelRepository : IChannelRepository .Include(c => c.Playouts) .ToListAsync(); } + + public async Task> GetWatermarkByName(string name) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + List maybeWatermarks = await dbContext.ChannelWatermarks + .Where(cw => EF.Functions.Like( + EF.Functions.Collate(cw.Name, TvContext.CaseInsensitiveCollation), + $"%{name}%")) + .ToListAsync(); + + return maybeWatermarks.HeadOrNone(); + } }