diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf5ff351..d99f1b16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - YAML playout: add `post_roll` instruction to enable and disable a post-roll sequence - With value of `true` and `sequence` property, will enable automatic post-roll for all content in the playout to the sequence with the provided key - With value of `false`, will disable automatic post-roll in the playout +- YAML playout: add `mid_roll` instruction to enable and disable a mid-roll sequence + - With value of `true` and `sequence` property, will enable automatic mid-roll for (`count` and `all`) content in the playout to the sequence with the provided key + - With value of `false`, will disable automatic post-roll in the playout + - `expression` can be used to influence which chapters are selected for mid roll (same as in filler preset) - Add YAML playout validation (using JSON Schema) - Invalid YAML playout definitions will fail to build and will log validation failures as warnings - `content` is fully validated @@ -27,8 +31,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `Playlist` collection type to filler presets - This will force filler mode `Count` - Whenever the filler is used, it will schedule `Count` times full time through the playlist - - If the playlist has 3 items and none configured to play all, it will schedule 3 items when `Count = 1` - - If the playlist has 3 items and none configured to play all, it will schedule 6 items when `Count = 2` + - If the playlist has 3 items and none set to play all, it will schedule 3 items when `Count = 1` + - If the playlist has 3 items and none set to play all, it will schedule 6 items when `Count = 2` - Using the same playlist in the same schedule for anything other than filler may cause undesired behavior ### Fixed diff --git a/ErsatzTV.Core.Tests/Scheduling/FillerExpressionTests.cs b/ErsatzTV.Core.Tests/Scheduling/FillerExpressionTests.cs index 71ad414b5..83d009620 100644 --- a/ErsatzTV.Core.Tests/Scheduling/FillerExpressionTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/FillerExpressionTests.cs @@ -34,7 +34,7 @@ public class FillerExpressionTests Expression = "(point > 5 * 60) and (last_mid_filler > 5 * 60) and (matched_points < 2)" }; - List result = FillerExpression.FilterChapters(fillerPreset, chapters, playoutItem); + List result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem); result.Count.ShouldBe(3); result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10)); @@ -67,7 +67,7 @@ public class FillerExpressionTests 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); + List result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem); result.Count.ShouldBe(3); result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10)); diff --git a/ErsatzTV.Core/Domain/PlayoutItem.cs b/ErsatzTV.Core/Domain/PlayoutItem.cs index 8d3ba1b46..065723fda 100644 --- a/ErsatzTV.Core/Domain/PlayoutItem.cs +++ b/ErsatzTV.Core/Domain/PlayoutItem.cs @@ -54,7 +54,17 @@ public class PlayoutItem Playout = Playout, InPoint = chapter.StartTime, OutPoint = chapter.EndTime, - ChapterTitle = chapter.Title + ChapterTitle = chapter.Title, + Watermark = Watermark, + WatermarkId = WatermarkId, + DisableWatermarks = DisableWatermarks, + PreferredAudioLanguageCode = PreferredAudioLanguageCode, + PreferredAudioTitle = PreferredAudioTitle, + PreferredSubtitleLanguageCode = PreferredSubtitleLanguageCode, + SubtitleMode = SubtitleMode, + BlockKey = BlockKey, + CollectionKey = CollectionKey, + CollectionEtag = CollectionEtag }; public string GetDisplayDuration() diff --git a/ErsatzTV.Core/Scheduling/FillerExpression.cs b/ErsatzTV.Core/Scheduling/FillerExpression.cs index f39a1b613..73a0ae274 100644 --- a/ErsatzTV.Core/Scheduling/FillerExpression.cs +++ b/ErsatzTV.Core/Scheduling/FillerExpression.cs @@ -1,14 +1,13 @@ using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Domain.Filler; using NCalc; namespace ErsatzTV.Core.Scheduling; public static class FillerExpression { - public static List FilterChapters(FillerPreset fillerPreset, List effectiveChapters, PlayoutItem playoutItem) + public static List FilterChapters(string fillerExpression, List effectiveChapters, PlayoutItem playoutItem) { - if (effectiveChapters.Count == 0 || fillerPreset is null || string.IsNullOrWhiteSpace(fillerPreset.Expression)) + if (effectiveChapters.Count == 0 || string.IsNullOrWhiteSpace(fillerExpression)) { return effectiveChapters; } @@ -27,7 +26,7 @@ public static class FillerExpression for (var index = 0; index < chapterPoints.Count; index++) { TimeSpan chapterPoint = chapterPoints[index]; - var expression = new Expression(fillerPreset.Expression); + var expression = new Expression(fillerExpression); int chapterNum = index + 1; double sinceLastFiller = chapterPoint.TotalSeconds - lastFiller; int matchedPoints = matches; diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs index b5cadf49a..7aee6b7a4 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs @@ -403,7 +403,7 @@ public abstract class PlayoutModeSchedulerBase : IPlayoutModeScheduler whe foreach (FillerPreset filler in allFiller.Filter(f => f.FillerKind == FillerKind.MidRoll && f.FillerMode != FillerMode.Pad)) { - List filteredChapters = FillerExpression.FilterChapters(filler, effectiveChapters, playoutItem); + List filteredChapters = FillerExpression.FilterChapters(filler.Expression, effectiveChapters, playoutItem); if (filteredChapters.Count <= 1) { result.Add(playoutItem); @@ -529,7 +529,7 @@ public abstract class PlayoutModeSchedulerBase : IPlayoutModeScheduler whe var totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks)); List filteredChapters = - FillerExpression.FilterChapters(padFiller, effectiveChapters, playoutItem); + FillerExpression.FilterChapters(padFiller.Expression, effectiveChapters, playoutItem); FillerKind fillerKind = padFiller.FillerKind; if (filteredChapters.Count <= 1 && effectiveChapters.Count > 1) diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs index c35a7b63c..bed814a71 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs @@ -71,7 +71,7 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou playoutItem.WatermarkId = watermarkId; } - context.Playout.Items.Add(playoutItem); + await AddItemAndMidRoll(context, playoutItem, executeSequence); context.AdvanceGuideGroup(); // create history record diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs index 461e54f78..6886ce937 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs @@ -163,4 +163,56 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache) return FillerKind.None; } + + protected static List ChaptersForMediaItem(MediaItem mediaItem) + { + MediaVersion version = mediaItem.GetHeadVersion(); + return Optional(version.Chapters).Flatten().OrderBy(c => c.StartTime).ToList(); + } + + protected static async Task AddItemAndMidRoll(YamlPlayoutContext context, PlayoutItem playoutItem, + Func executeSequence) + { + List itemChapters = ChaptersForMediaItem(playoutItem.MediaItem); + Option maybeMidRollSequence = context.GetMidRollSequence(); + if (itemChapters.Count < 2 || maybeMidRollSequence.IsNone) + { + context.Playout.Items.Add(playoutItem); + } + else + { + foreach (var midRollSequence in maybeMidRollSequence) + { + var filteredChapters = FillerExpression.FilterChapters( + midRollSequence.Expression, + itemChapters, + playoutItem); + + if (filteredChapters.Count < 2) + { + context.Playout.Items.Add(playoutItem); + } + else + { + for (var j = 0; j < filteredChapters.Count; j++) + { + var nextItem = playoutItem.ForChapter(filteredChapters[j]); + + nextItem.Start = context.CurrentTime.UtcDateTime; + nextItem.Finish = context.CurrentTime.UtcDateTime + (nextItem.OutPoint - nextItem.InPoint); + context.Playout.Items.Add(nextItem); + + context.CurrentTime += nextItem.OutPoint - nextItem.InPoint; + + if (j < filteredChapters.Count - 1) + { + context.PushFillerKind(FillerKind.MidRoll); + await executeSequence(midRollSequence.Sequence); + context.PopFillerKind(); + } + } + } + } + } + } } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs index 32d4e1752..83bc505b9 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs @@ -96,7 +96,7 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay playoutItem.WatermarkId = watermarkId; } - context.Playout.Items.Add(playoutItem); + await AddItemAndMidRoll(context, playoutItem, executeSequence); context.AdvanceGuideGroup(); // create history record diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutMidRollHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutMidRollHandler.cs new file mode 100644 index 000000000..18506620d --- /dev/null +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutMidRollHandler.cs @@ -0,0 +1,34 @@ +using ErsatzTV.Core.Scheduling.YamlScheduling.Models; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; + +public class YamlPlayoutMidRollHandler : IYamlPlayoutHandler +{ + public bool Reset => false; + + public Task Handle( + YamlPlayoutContext context, + YamlPlayoutInstruction instruction, + PlayoutBuildMode mode, + Func executeSequence, + ILogger logger, + CancellationToken cancellationToken) + { + if (instruction is not YamlMidRollInstruction midRoll) + { + return Task.FromResult(false); + } + + if (midRoll.MidRoll && !string.IsNullOrWhiteSpace(midRoll.Sequence) && !string.IsNullOrWhiteSpace(midRoll.Expression)) + { + context.SetMidRollSequence(new YamlPlayoutContext.MidRollSequence(midRoll.Sequence, midRoll.Expression)); + } + else + { + context.ClearMidRollSequence(); + } + + return Task.FromResult(true); + } +} diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlMidRollInstruction.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlMidRollInstruction.cs new file mode 100644 index 000000000..1efb96782 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlMidRollInstruction.cs @@ -0,0 +1,13 @@ +using YamlDotNet.Serialization; + +namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models; + +public class YamlMidRollInstruction : YamlPlayoutInstruction +{ + [YamlMember(Alias = "mid_roll", ApplyNamingConventions = false)] + public bool MidRoll { get; set; } + + public string Sequence { get; set; } + + public string Expression { get; set; } = "true"; +} diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs index da08bd17e..36745a061 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs @@ -345,6 +345,7 @@ public class YamlPlayoutBuilder( YamlPlayoutShuffleSequenceInstruction => new YamlPlayoutShuffleSequenceHandler(), YamlPreRollInstruction => new YamlPlayoutPreRollHandler(), YamlPostRollInstruction => new YamlPlayoutPostRollHandler(), + YamlMidRollInstruction => new YamlPlayoutMidRollHandler(), YamlPlayoutSkipItemsInstruction => new YamlPlayoutSkipItemsHandler(enumeratorCache), YamlPlayoutSkipToItemInstruction => new YamlPlayoutSkipToItemHandler(enumeratorCache), @@ -405,6 +406,7 @@ public class YamlPlayoutBuilder( { "pad_until", typeof(YamlPlayoutPadUntilInstruction) }, { "pre_roll", typeof(YamlPreRollInstruction) }, { "post_roll", typeof(YamlPostRollInstruction) }, + { "mid_roll", typeof(YamlMidRollInstruction) }, { "repeat", typeof(YamlPlayoutRepeatInstruction) }, { "sequence", typeof(YamlPlayoutSequenceInstruction) }, { "shuffle_sequence", typeof(YamlPlayoutShuffleSequenceInstruction) }, diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs index 614355053..571e80a15 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs @@ -20,6 +20,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio private Option _channelWatermarkId; private Option _preRollSequence; private Option _postRollSequence; + private Option _midRollSequence; public Playout Playout { get; } = playout; @@ -117,6 +118,18 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio public Option GetPostRollSequence() => _postRollSequence; + public void SetMidRollSequence(MidRollSequence sequence) + { + _midRollSequence = sequence; + } + + public void ClearMidRollSequence() + { + _midRollSequence = Option.None; + } + + public Option GetMidRollSequence() => _midRollSequence; + public void PushFillerKind(FillerKind fillerKind) { _fillerKind.Push(fillerKind); @@ -188,4 +201,6 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio bool? GuideGroupLocked, int? ChannelWatermarkId, string PreRollSequence); + + public record MidRollSequence(string Sequence, string Expression); } diff --git a/ErsatzTV/Resources/yaml-playout.schema.json b/ErsatzTV/Resources/yaml-playout.schema.json index eb4e9f7e9..762f144d0 100644 --- a/ErsatzTV/Resources/yaml-playout.schema.json +++ b/ErsatzTV/Resources/yaml-playout.schema.json @@ -41,6 +41,7 @@ { "$ref": "#/$defs/control/epgGroupInstruction" }, { "$ref": "#/$defs/control/preRollInstruction"}, { "$ref": "#/$defs/control/postRollInstruction"}, + { "$ref": "#/$defs/control/midRollInstruction"}, { "$ref": "#/$defs/control/repeatInstruction" }, { "$ref": "#/$defs/control/shuffleSequenceInstruction" }, { "$ref": "#/$defs/control/skipItemsInstruction" }, @@ -81,6 +82,7 @@ { "$ref": "#/$defs/control/epgGroupInstruction" }, { "$ref": "#/$defs/control/preRollInstruction"}, { "$ref": "#/$defs/control/postRollInstruction"}, + { "$ref": "#/$defs/control/midRollInstruction"}, { "$ref": "#/$defs/control/repeatInstruction" }, { "$ref": "#/$defs/control/shuffleSequenceInstruction" }, { "$ref": "#/$defs/control/skipItemsInstruction" }, @@ -309,6 +311,16 @@ "required": [ "post_roll" ], "additionalProperties": false }, + "midRollInstruction": { + "type": "object", + "properties": { + "mid_roll": { "type": "boolean" }, + "sequence": { "type": "string" }, + "expression": { "type": "string" } + }, + "required": [ "mid_roll" ], + "additionalProperties": false + }, "repeatInstruction": { "type": "object", "properties": {