diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e65a5b5..3db8018ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix health checks causing a flood of (harmless) logged errors when quickly navigating away from home page - Health check results will now be cached for 5 minutes by default; a refresh button has been added to immediately re-run all checks - Fix `/api/sessions` response when channels use Next streaming engine +- Save and restore sequential schedule mid-roll, post-roll, and graphics state between builds ## [26.9.0] - 2026-09-06 ### Fixed diff --git a/ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutGraphicsTests.cs b/ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutGraphicsTests.cs new file mode 100644 index 000000000..189cecdec --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutGraphicsTests.cs @@ -0,0 +1,105 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Scheduling.YamlScheduling; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; + +namespace ErsatzTV.Core.Tests.Scheduling; + +[TestFixture] +public class SequentialPlayoutGraphicsTests +{ + [Test] + [CancelAfter(30_000)] + public async Task Continue_Should_Keep_Graphics_Element_On_Every_Item(CancellationToken cancellationToken) + { + string scheduleFile = Path.GetTempFileName(); + const string schedule = """ + content: + - smart_collection: Programme + key: programme + order: chronological + playout: + - graphics_on: bug.json + variables: + title: hello + - count: 2 + content: programme + - count: 2 + content: programme + - repeat: true + """; + await File.WriteAllTextAsync(scheduleFile, schedule, cancellationToken); + try + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(scheduleFile)); + fileSystem.File.WriteAllText(scheduleFile, schedule); + IConfigElementRepository config = Substitute.For(); + config.GetValue(Arg.Any(), Arg.Any()) + .Returns(Some(1)); + IMediaCollectionRepository media = Substitute.For(); + media.GetSmartCollectionItemsByName(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new List + { + new Movie + { + Id = 1, + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromHours(12) }], + MovieMetadata = [new MovieMetadata { Title = "Programme" }] + } + })); + IGraphicsElementRepository graphics = Substitute.For(); + graphics.GetGraphicsElementByPath("bug.json", Arg.Any()) + .Returns(Some(new GraphicsElement { Id = 42, Path = "bug.json" })); + ISequentialScheduleValidator validator = Substitute.For(); + validator.ValidateSchedule(Arg.Any(), false).Returns(true); + var builder = new SequentialPlayoutBuilder( + fileSystem, config, media, Substitute.For(), + graphics, validator, + NullLogger.Instance); + var channel = new Channel(Guid.NewGuid()) { Id = 1, Number = "1", Name = "Graphics test" }; + var playout = new Playout + { + Id = 1, ChannelId = 1, Channel = channel, ScheduleFile = scheduleFile, + ScheduleKind = PlayoutScheduleKind.Sequential, Seed = 12345, + Items = [], PlayoutHistory = [] + }; + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var items = new List(); + var history = new List(); + for (var build = 0; build < 3; build++) + { + var reference = new PlayoutReferenceData( + channel, Option.None, items, [], null, [], history, TimeSpan.Zero); + var result = await builder.Build( + start.AddDays(build), playout, reference, + build == 0 ? PlayoutBuildMode.Reset : PlayoutBuildMode.Continue, + cancellationToken); + result.IsRight.ShouldBeTrue(); + PlayoutBuildResult built = result.RightToSeq().Single(); + built.AddedItems.Count.ShouldBe(2, $"build {build} must add two programmes"); + foreach (PlayoutItem item in built.AddedItems) + { + PlayoutItemGraphicsElement element = item.PlayoutItemGraphicsElements + .ShouldHaveSingleItem($"build {build} must keep the graphics element on every programme"); + element.GraphicsElementId.ShouldBe(42); + element.Variables.ShouldBe("{\"title\":\"hello\"}"); + } + + items.AddRange(built.AddedItems); + history.AddRange(built.AddedHistory); + } + } + finally + { + File.Delete(scheduleFile); + } + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutPostRollTests.cs b/ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutPostRollTests.cs new file mode 100644 index 000000000..84044f410 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutPostRollTests.cs @@ -0,0 +1,118 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Scheduling.YamlScheduling; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; + +namespace ErsatzTV.Core.Tests.Scheduling; + +[TestFixture] +public class SequentialPlayoutPostRollTests +{ + [Test] + [CancelAfter(30_000)] + public async Task Continue_Should_Keep_PostRoll_After_Every_Item(CancellationToken cancellationToken) + { + string scheduleFile = Path.GetTempFileName(); + const string schedule = """ + content: + - smart_collection: Programme + key: programme + order: chronological + - smart_collection: Ident + key: ident + order: shuffle + sequence: + - key: ident-break + items: + - count: 1 + content: ident + filler_kind: postroll + disable_watermarks: true + playout: + - post_roll: true + sequence: ident-break + - count: 2 + content: programme + - count: 2 + content: programme + - repeat: true + """; + await File.WriteAllTextAsync(scheduleFile, schedule, cancellationToken); + try + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(scheduleFile)); + fileSystem.File.WriteAllText(scheduleFile, schedule); + IConfigElementRepository config = Substitute.For(); + config.GetValue(Arg.Any(), Arg.Any()) + .Returns(Some(1)); + IMediaCollectionRepository media = Substitute.For(); + media.GetSmartCollectionItemsByName(Arg.Any(), Arg.Any()) + .Returns(call => Task.FromResult(new List + { + new Movie + { + Id = (string)call[0] == "Ident" ? 2 : 1, + MediaVersions = [new MediaVersion + { + Duration = (string)call[0] == "Ident" + ? TimeSpan.FromSeconds(10) + : TimeSpan.FromHours(12) + }], + MovieMetadata = [new MovieMetadata { Title = (string)call[0] }] + } + })); + ISequentialScheduleValidator validator = Substitute.For(); + validator.ValidateSchedule(Arg.Any(), false).Returns(true); + var builder = new SequentialPlayoutBuilder( + fileSystem, config, media, Substitute.For(), + Substitute.For(), validator, + NullLogger.Instance); + var channel = new Channel(Guid.NewGuid()) { Id = 1, Number = "1", Name = "Post-roll test" }; + var playout = new Playout + { + Id = 1, ChannelId = 1, Channel = channel, ScheduleFile = scheduleFile, + ScheduleKind = PlayoutScheduleKind.Sequential, Seed = 12345, + Items = [], PlayoutHistory = [] + }; + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var items = new List(); + var history = new List(); + for (var build = 0; build < 3; build++) + { + var reference = new PlayoutReferenceData( + channel, Option.None, items, [], null, [], history, TimeSpan.Zero); + var result = await builder.Build( + start.AddDays(build), playout, reference, + build == 0 ? PlayoutBuildMode.Reset : PlayoutBuildMode.Continue, + cancellationToken); + result.IsRight.ShouldBeTrue(); + PlayoutBuildResult built = result.RightToSeq().Single(); + built.AddedItems.Select(i => i.MediaItemId).ShouldBe([1, 2, 1, 2], + $"build {build} must insert one ident after each programme"); + built.AddedItems.Where(i => i.MediaItemId == 2) + .ShouldAllBe(i => i.DisableWatermarks); + if (build > 0) + { + built.ClearItems.ShouldBeFalse(); + built.RemoveAfter.IsNone.ShouldBeTrue(); + built.AddedItems.First().Start.ShouldBe(items.Last().Finish); + } + + items.AddRange(built.AddedItems); + history.AddRange(built.AddedHistory); + } + } + finally + { + File.Delete(scheduleFile); + } + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs index 66157b7a7..d8e9c6049 100644 --- a/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs @@ -9,6 +9,128 @@ namespace ErsatzTV.Core.Tests.Scheduling; public static class YamlPlayoutContextTests { + [TestFixture] + public class PostRollPersistence + { + [TestCase(null)] + [TestCase("Christmas")] + public void Checkpoint_Should_Preserve_PostRoll(string activeSchedule) + { + var context = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + context.SwitchToSchedule(activeSchedule); + context.SetPostRollSequence("idents"); + context.InstructionIndex = 4; + var restored = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + restored.Reset(new PlayoutAnchor + { + NextStart = DateTime.UtcNow, + Context = context.Serialize() + }, DateTimeOffset.UtcNow); + restored.GetPostRollSequence().ShouldBe(Some("idents")); + restored.InstructionIndex.ShouldBe(4); + } + + [Test] + public void Checkpoint_Should_Preserve_Explicitly_Disabled_PostRoll() + { + var context = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + context.SetPostRollSequence("idents"); + context.ClearPostRollSequence(); + var restored = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + restored.SetPostRollSequence("stale"); + restored.Reset(new PlayoutAnchor + { + NextStart = DateTime.UtcNow, + Context = context.Serialize() + }, DateTimeOffset.UtcNow); + restored.GetPostRollSequence().IsNone.ShouldBeTrue(); + } + + [Test] + public void Old_Checkpoint_Should_Remain_Readable_Without_Guessing_PostRoll() + { + var context = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + context.Reset(new PlayoutAnchor + { + NextStart = DateTime.UtcNow, + Context = "{\"InstructionIndex\":4,\"ChannelWatermarkIds\":[]}" + }, DateTimeOffset.UtcNow); + context.InstructionIndex.ShouldBe(4); + context.GetPostRollSequence().IsNone.ShouldBeTrue(); + } + } + + [TestFixture] + public class GraphicsAndMidRollPersistence + { + [Test] + public void Checkpoint_Should_Preserve_Graphics_Elements() + { + var context = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + context.SetGraphicsElement(7, null); + context.SetGraphicsElement(8, "{\"title\":\"x\"}"); + var restored = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + restored.Reset(new PlayoutAnchor + { + NextStart = DateTime.UtcNow, + Context = context.Serialize() + }, DateTimeOffset.UtcNow); + restored.GetGraphicsElements().Count.ShouldBe(2); + restored.GetGraphicsElements()[7].ShouldBeNull(); + restored.GetGraphicsElements()[8].ShouldBe("{\"title\":\"x\"}"); + } + + [Test] + public void Checkpoint_Should_Preserve_MidRoll() + { + var context = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + context.SetMidRollSequence(new YamlPlayoutContext.MidRollSequence("ads", "count > 1")); + var restored = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + restored.Reset(new PlayoutAnchor + { + NextStart = DateTime.UtcNow, + Context = context.Serialize() + }, DateTimeOffset.UtcNow); + restored.GetMidRollSequence() + .ShouldBe(Some(new YamlPlayoutContext.MidRollSequence("ads", "count > 1"))); + } + + [Test] + public void Checkpoint_Should_Preserve_Cleared_Graphics_And_MidRoll() + { + var context = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + context.SetGraphicsElement(7, null); + context.ClearGraphicsElements(); + context.SetMidRollSequence(new YamlPlayoutContext.MidRollSequence("ads", "true")); + context.ClearMidRollSequence(); + var restored = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + restored.SetGraphicsElement(9, null); + restored.SetMidRollSequence(new YamlPlayoutContext.MidRollSequence("stale", "true")); + restored.Reset(new PlayoutAnchor + { + NextStart = DateTime.UtcNow, + Context = context.Serialize() + }, DateTimeOffset.UtcNow); + restored.GetGraphicsElements().ShouldBeEmpty(); + restored.GetMidRollSequence().IsNone.ShouldBeTrue(); + } + + [Test] + public void Old_Checkpoint_Should_Remain_Readable_Without_Graphics_Or_MidRoll() + { + var context = new YamlPlayoutContext(new Playout(), new YamlPlayoutDefinition(), 1); + context.Reset(new PlayoutAnchor + { + NextStart = DateTime.UtcNow, + Context = "{\"InstructionIndex\":4,\"ChannelWatermarkIds\":[],\"PostRollSequence\":\"idents\"}" + }, DateTimeOffset.UtcNow); + context.InstructionIndex.ShouldBe(4); + context.GetPostRollSequence().ShouldBe(Some("idents")); + context.GetGraphicsElements().ShouldBeEmpty(); + context.GetMidRollSequence().IsNone.ShouldBeTrue(); + } + } + [TestFixture] public class ScheduleSwitching { diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs index e2737d26d..77b6e8e1a 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs @@ -431,6 +431,22 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio preRollSequence = sequence; } + string postRollSequence = null; + foreach (string sequence in _postRollSequence) + { + postRollSequence = sequence; + } + + MidRollSequence midRollSequence = null; + foreach (MidRollSequence sequence in _midRollSequence) + { + midRollSequence = sequence; + } + + Dictionary graphicsElements = _graphicsElements.Count > 0 + ? new Dictionary(_graphicsElements) + : null; + // capture the current active list index alongside the other saved list indices var scheduleIndices = _listStates.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.InstructionIndex); scheduleIndices[_activeSchedule ?? string.Empty] = _instructionIndex; @@ -444,7 +460,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio _activeSchedule, scheduleIndices, CaptureSequenceOrders(), - _listFingerprints.Count > 0 ? new Dictionary(_listFingerprints) : null); + _listFingerprints.Count > 0 ? new Dictionary(_listFingerprints) : null, + postRollSequence, + midRollSequence, + graphicsElements); return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings); } @@ -489,6 +508,18 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio _preRollSequence = preRollSequence; } + _postRollSequence = Optional(state.PostRollSequence); + _midRollSequence = Optional(state.MidRollSequence); + + _graphicsElements.Clear(); + if (state.GraphicsElements is not null) + { + foreach ((int id, string variables) in state.GraphicsElements) + { + _graphicsElements[id] = variables; + } + } + _listFingerprintsToRestore = state.ListFingerprints; _sequenceOrdersToRestore = state.SequenceOrders; @@ -558,7 +589,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio string ActiveSchedule = null, Dictionary ScheduleIndices = null, Dictionary> SequenceOrders = null, - Dictionary ListFingerprints = null); + Dictionary ListFingerprints = null, + string PostRollSequence = null, + MidRollSequence MidRollSequence = null, + Dictionary GraphicsElements = null); public record SequenceOrder(string Sequence, List Order);