Browse Source

fix: preserve YAML post-roll state across playout builds (#3013)

* fix: preserve YAML post-roll state across playout builds

* also serialize mid roll and graphics

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
pull/3014/head
p0ns 5 days ago committed by GitHub
parent
commit
b3f7f3fe56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 105
      ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutGraphicsTests.cs
  3. 118
      ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutPostRollTests.cs
  4. 122
      ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs
  5. 38
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs

1
CHANGELOG.md

@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

105
ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutGraphicsTests.cs

@ -0,0 +1,105 @@ @@ -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<IConfigElementRepository>();
config.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Some(1));
IMediaCollectionRepository media = Substitute.For<IMediaCollectionRepository>();
media.GetSmartCollectionItemsByName(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new List<MediaItem>
{
new Movie
{
Id = 1,
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromHours(12) }],
MovieMetadata = [new MovieMetadata { Title = "Programme" }]
}
}));
IGraphicsElementRepository graphics = Substitute.For<IGraphicsElementRepository>();
graphics.GetGraphicsElementByPath("bug.json", Arg.Any<CancellationToken>())
.Returns(Some(new GraphicsElement { Id = 42, Path = "bug.json" }));
ISequentialScheduleValidator validator = Substitute.For<ISequentialScheduleValidator>();
validator.ValidateSchedule(Arg.Any<string>(), false).Returns(true);
var builder = new SequentialPlayoutBuilder(
fileSystem, config, media, Substitute.For<IChannelRepository>(),
graphics, validator,
NullLogger<SequentialPlayoutBuilder>.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<PlayoutItem>();
var history = new List<PlayoutHistory>();
for (var build = 0; build < 3; build++)
{
var reference = new PlayoutReferenceData(
channel, Option<Deco>.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);
}
}
}

118
ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutPostRollTests.cs

@ -0,0 +1,118 @@ @@ -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<IConfigElementRepository>();
config.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Some(1));
IMediaCollectionRepository media = Substitute.For<IMediaCollectionRepository>();
media.GetSmartCollectionItemsByName(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(call => Task.FromResult(new List<MediaItem>
{
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<ISequentialScheduleValidator>();
validator.ValidateSchedule(Arg.Any<string>(), false).Returns(true);
var builder = new SequentialPlayoutBuilder(
fileSystem, config, media, Substitute.For<IChannelRepository>(),
Substitute.For<IGraphicsElementRepository>(), validator,
NullLogger<SequentialPlayoutBuilder>.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<PlayoutItem>();
var history = new List<PlayoutHistory>();
for (var build = 0; build < 3; build++)
{
var reference = new PlayoutReferenceData(
channel, Option<Deco>.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);
}
}
}

122
ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs

@ -9,6 +9,128 @@ namespace ErsatzTV.Core.Tests.Scheduling; @@ -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
{

38
ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs

@ -431,6 +431,22 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -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<int, string> graphicsElements = _graphicsElements.Count > 0
? new Dictionary<int, string>(_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 @@ -444,7 +460,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
_activeSchedule,
scheduleIndices,
CaptureSequenceOrders(),
_listFingerprints.Count > 0 ? new Dictionary<string, string>(_listFingerprints) : null);
_listFingerprints.Count > 0 ? new Dictionary<string, string>(_listFingerprints) : null,
postRollSequence,
midRollSequence,
graphicsElements);
return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings);
}
@ -489,6 +508,18 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -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 @@ -558,7 +589,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
string ActiveSchedule = null,
Dictionary<string, int> ScheduleIndices = null,
Dictionary<string, List<SequenceOrder>> SequenceOrders = null,
Dictionary<string, string> ListFingerprints = null);
Dictionary<string, string> ListFingerprints = null,
string PostRollSequence = null,
MidRollSequence MidRollSequence = null,
Dictionary<int, string> GraphicsElements = null);
public record SequenceOrder(string Sequence, List<int> Order);

Loading…
Cancel
Save