Browse Source

fix: preserve YAML shuffle order across builds (#2993)

* Fix YAML shuffle resume state

* fix ambiguity with schedule naming

* fix shuffle sequence endless loops

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
pull/2994/head
p0ns 2 weeks ago committed by GitHub
parent
commit
722f1b9320
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 312
      ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutShuffleTests.cs
  3. 301
      ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs
  4. 34
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs
  5. 11
      ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs
  6. 26
      ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs
  7. 265
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs

5
CHANGELOG.md

@ -9,6 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -9,6 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Next engine:
- Properly detect QSV capabilities for Intel 10th gen and older devices; previously they always used software transcoding
- Fix block scheduler deleting the current hour's playout items, taking the channel offline until the next block
- Sequential schedules:
- Fix `shuffle_sequence` losing the shuffled order at the end of each build; previously the next build continued in schedule file order
- Fix `shuffle_sequence` deleting the instructions between two uses of the same sequence
- Fix a sequence that is used two times giving the `custom_title` of the last use to every use
- Fix a shuffled sequence with `repeat` making the build run with no end; this stopped all other background work
## [26.8.1] - 2026-08-29
### Security

312
ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutShuffleTests.cs

@ -0,0 +1,312 @@ @@ -0,0 +1,312 @@
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 ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class SequentialPlayoutShuffleTests
{
private const int SequenceLength = 20;
[Test]
public async Task Shuffle_Should_Preserve_Nested_Sequence_Positions()
{
var shuffle = new YamlPlayoutShuffleSequenceInstruction { ShuffleSequence = "outer" };
var outerGuid = Guid.NewGuid();
var first = new YamlPlayoutInstruction
{
Content = "first",
SequenceKey = "outer",
SequenceGuid = outerGuid
};
var nested = new YamlPlayoutInstruction
{
Content = "nested",
SequenceKey = "inner",
SequenceGuid = Guid.NewGuid()
};
var second = new YamlPlayoutInstruction
{
Content = "second",
SequenceKey = "outer",
SequenceGuid = outerGuid
};
var definition = new YamlPlayoutDefinition { Playout = [shuffle, first, nested, second] };
var context = new YamlPlayoutContext(new Playout(), definition, 1);
var handler = new YamlPlayoutShuffleSequenceHandler();
bool result = await handler.Handle(
context,
shuffle,
PlayoutBuildMode.Reset,
_ => Task.CompletedTask,
NullLogger<SequentialPlayoutBuilder>.Instance,
CancellationToken.None);
result.ShouldBeTrue();
definition.Playout.Count.ShouldBe(4);
definition.Playout[2].ShouldBeSameAs(nested);
definition.Playout.Where(i => i.SequenceKey == "outer").Select(i => i.Content).Order()
.ShouldBe(["first", "second"]);
}
[Test]
public void Shuffle_Should_Give_Up_When_Every_Draw_Starts_With_The_Tail()
{
// a group of repeated objects can never draw a head that differs from the tail by reference
(YamlPlayoutDefinition definition, YamlPlayoutShuffleSequenceInstruction shuffle) =
CreateRepeatedInstructionDefinition();
var handler = new YamlPlayoutShuffleSequenceHandler();
Task<bool> handle = Task.Run(() => handler.Handle(
new YamlPlayoutContext(new Playout(), definition, 1),
shuffle,
PlayoutBuildMode.Reset,
_ => Task.CompletedTask,
NullLogger<SequentialPlayoutBuilder>.Instance,
CancellationToken.None));
handle.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue("the shuffle must not retry forever");
handle.Result.ShouldBeTrue();
definition.Playout.Count.ShouldBe(4);
}
[Test]
public void Shuffle_Should_Stop_Retrying_When_Cancelled()
{
(YamlPlayoutDefinition definition, YamlPlayoutShuffleSequenceInstruction shuffle) =
CreateRepeatedInstructionDefinition();
var handler = new YamlPlayoutShuffleSequenceHandler();
using var cts = new CancellationTokenSource();
cts.Cancel();
Task<bool> handle = Task.Run(() => handler.Handle(
new YamlPlayoutContext(new Playout(), definition, 1),
shuffle,
PlayoutBuildMode.Reset,
_ => Task.CompletedTask,
NullLogger<SequentialPlayoutBuilder>.Instance,
cts.Token));
handle.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue("a cancelled build must interrupt the shuffle");
handle.Result.ShouldBeTrue();
}
private static (YamlPlayoutDefinition Definition, YamlPlayoutShuffleSequenceInstruction Shuffle)
CreateRepeatedInstructionDefinition()
{
var shuffle = new YamlPlayoutShuffleSequenceInstruction { ShuffleSequence = "shows" };
var repeated = new YamlPlayoutInstruction
{
Content = "show",
SequenceKey = "shows",
SequenceGuid = Guid.NewGuid()
};
return (new YamlPlayoutDefinition { Playout = [shuffle, repeated, repeated, repeated] }, shuffle);
}
[Test]
[CancelAfter(30_000)]
public async Task Continue_Should_Resume_The_Saved_Shuffled_Order(CancellationToken cancellationToken)
{
string scheduleFile = Path.GetTempFileName();
string schedule = BuildSchedule();
await File.WriteAllTextAsync(scheduleFile, schedule, cancellationToken);
var fileSystem = new MockFileSystem();
fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(scheduleFile));
fileSystem.File.WriteAllText(scheduleFile, schedule);
try
{
IConfigElementRepository config = Substitute.For<IConfigElementRepository>();
config
.GetValue<int>(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any<CancellationToken>())
.Returns(Some(2));
Dictionary<string, MediaItem> mediaItems = Enumerable.Range(1, SequenceLength).ToDictionary(
i => $"Show {i:00}",
i => (MediaItem)new Movie
{
Id = i,
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromHours(1) }],
MovieMetadata =
[
new MovieMetadata
{
Title = $"Show {i:00}",
ReleaseDate = new DateTime(2000, 1, i)
}
]
});
IMediaCollectionRepository media = Substitute.For<IMediaCollectionRepository>();
media
.GetSmartCollectionItemsByName(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(call => Task.FromResult(new List<MediaItem> { mediaItems[(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 = "Shuffle test" };
var playout = new Playout
{
Id = 1,
ChannelId = channel.Id,
Channel = channel,
ScheduleFile = scheduleFile,
ScheduleKind = PlayoutScheduleKind.Sequential,
Seed = 12345,
Items = [],
PlayoutHistory = []
};
var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
PlayoutBuildResult first = await Build(
builder,
start,
playout,
channel,
[],
[],
PlayoutBuildMode.Reset,
cancellationToken);
YamlPlayoutContext.State savedState =
JsonConvert.DeserializeObject<YamlPlayoutContext.State>(playout.Anchor.Context);
savedState.InstructionIndex.ShouldBe(9);
savedState.SequenceOrders.ShouldContainKey(string.Empty);
List<int> savedOrder = savedState.SequenceOrders[string.Empty].Single().Order;
PlayoutBuildResult second = await Build(
builder,
start.AddDays(2),
playout,
channel,
first.AddedItems,
first.AddedHistory,
PlayoutBuildMode.Continue,
cancellationToken);
AssertStartsWithSavedRemainder(second, savedState, savedOrder);
YamlPlayoutContext.State secondSavedState =
JsonConvert.DeserializeObject<YamlPlayoutContext.State>(playout.Anchor.Context);
List<int> secondSavedOrder = secondSavedState.SequenceOrders[string.Empty].Single().Order;
PlayoutBuildResult third = await Build(
builder,
start.AddDays(4),
playout,
channel,
second.AddedItems,
first.AddedHistory.Concat(second.AddedHistory).ToList(),
PlayoutBuildMode.Continue,
cancellationToken);
AssertStartsWithSavedRemainder(third, secondSavedState, secondSavedOrder);
List<int> declarationOrder = Enumerable.Range(1, SequenceLength).ToList();
List<int> combined = first.AddedItems
.Concat(second.AddedItems)
.Concat(third.AddedItems)
.OrderBy(i => i.Start)
.Select(i => i.MediaItemId)
.ToList();
foreach (int[] rotation in combined.Chunk(SequenceLength).Where(chunk => chunk.Length == SequenceLength))
{
rotation.Order().ShouldBe(declarationOrder);
}
}
finally
{
File.Delete(scheduleFile);
}
}
private static void AssertStartsWithSavedRemainder(
PlayoutBuildResult result,
YamlPlayoutContext.State savedState,
List<int> savedOrder)
{
List<int> expectedRemainder = savedOrder
.Skip(savedState.InstructionIndex.Value - 1)
.Select(index => index + 1)
.ToList();
result.AddedItems
.OrderBy(i => i.Start)
.Take(expectedRemainder.Count)
.Select(i => i.MediaItemId)
.ShouldBe(expectedRemainder);
}
private static async Task<PlayoutBuildResult> Build(
SequentialPlayoutBuilder builder,
DateTimeOffset start,
Playout playout,
Channel channel,
List<PlayoutItem> existingItems,
List<PlayoutHistory> history,
PlayoutBuildMode mode,
CancellationToken cancellationToken)
{
var referenceData = new PlayoutReferenceData(
channel,
Option<Deco>.None,
existingItems,
[],
null,
[],
history,
TimeSpan.Zero);
var buildResult = await builder.Build(start, playout, referenceData, mode, cancellationToken);
buildResult.IsRight.ShouldBeTrue();
return buildResult.RightToSeq().Single();
}
private static string BuildSchedule()
{
var yaml = new System.Text.StringBuilder("content:\n");
for (var i = 1; i <= SequenceLength; i++)
{
yaml.AppendLine($" - smart_collection: Show {i:00}");
yaml.AppendLine($" key: show-{i:00}");
yaml.AppendLine(" order: chronological");
}
yaml.AppendLine("sequence:");
yaml.AppendLine(" - key: shows");
yaml.AppendLine(" items:");
for (var i = 1; i <= SequenceLength; i++)
{
yaml.AppendLine(" - count: 1");
yaml.AppendLine($" content: show-{i:00}");
}
yaml.AppendLine("playout:");
yaml.AppendLine(" - shuffle_sequence: shows");
yaml.AppendLine(" - sequence: shows");
yaml.AppendLine(" - repeat: true");
return yaml.ToString();
}
}

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

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
using Newtonsoft.Json;
using NUnit.Framework;
using Shouldly;
@ -74,4 +75,304 @@ public static class YamlPlayoutContextTests @@ -74,4 +75,304 @@ public static class YamlPlayoutContextTests
context.GetGraphicsElements()[1].ShouldBe("b");
}
}
[TestFixture]
public class SequenceOrderPersistence
{
[Test]
public void Restoring_Should_Keep_Shuffled_Orders_For_All_Playout_Lists()
{
YamlPlayoutDefinition savedDefinition = CreateDefinition([2, 0, 3, 1], [1, 3, 0, 2], true);
var savedContext = new YamlPlayoutContext(new Playout(), savedDefinition, 1)
{
InstructionIndex = 3
};
savedContext.RestoreSequenceOrders();
var anchor = new PlayoutAnchor
{
NextStart = DateTime.UtcNow,
Context = savedContext.Serialize()
};
YamlPlayoutDefinition restoredDefinition = CreateDefinition([0, 1, 2, 3], [0, 1, 2, 3], false);
var restoredContext = new YamlPlayoutContext(new Playout(), restoredDefinition, 1);
restoredContext.Reset(anchor, DateTimeOffset.Now);
restoredContext.RestoreSequenceOrders();
restoredDefinition.Playout.Select(i => i.Content).ShouldBe(["show-2", "show-0", "show-3", "show-1"]);
restoredDefinition.Schedules[0].Playout.Select(i => i.Content)
.ShouldBe(["show-1", "show-3", "show-0", "show-2"]);
restoredContext.InstructionIndex.ShouldBe(3);
}
[Test]
public void Restoring_Should_Reset_The_List_When_The_Sequence_Changed()
{
YamlPlayoutDefinition savedDefinition = CreateDefinition([2, 0, 3, 1], [0, 1, 2, 3], true, "old");
var savedContext = new YamlPlayoutContext(new Playout(), savedDefinition, 1)
{
InstructionIndex = 3
};
savedContext.RestoreSequenceOrders();
var anchor = new PlayoutAnchor
{
NextStart = DateTime.UtcNow,
Context = savedContext.Serialize()
};
YamlPlayoutDefinition restoredDefinition = CreateDefinition([0, 1, 2, 3], [0, 1, 2, 3], false, "new");
var restoredContext = new YamlPlayoutContext(new Playout(), restoredDefinition, 1);
restoredContext.Reset(anchor, DateTimeOffset.Now);
restoredContext.RestoreSequenceOrders();
restoredDefinition.Playout.Select(i => i.Content).ShouldBe(["show-0", "show-1", "show-2", "show-3"]);
restoredContext.InstructionIndex.ShouldBe(0);
}
[Test]
public void Restoring_Should_Reset_The_List_When_Its_Layout_Changed()
{
YamlPlayoutDefinition savedDefinition = CreateDefinition([2, 0, 3, 1], [0, 1, 2, 3], true);
var savedContext = new YamlPlayoutContext(new Playout(), savedDefinition, 1)
{
InstructionIndex = 3
};
savedContext.RestoreSequenceOrders();
var anchor = new PlayoutAnchor
{
NextStart = DateTime.UtcNow,
Context = savedContext.Serialize()
};
YamlPlayoutDefinition restoredDefinition = CreateDefinition([0, 1, 2, 3], [0, 1, 2, 3], false);
restoredDefinition.Playout.Insert(0, new YamlPlayoutInstruction { Content = "new-item" });
var restoredContext = new YamlPlayoutContext(new Playout(), restoredDefinition, 1);
restoredContext.Reset(anchor, DateTimeOffset.Now);
restoredContext.RestoreSequenceOrders();
restoredContext.InstructionIndex.ShouldBe(0);
}
[Test]
public void Restoring_Should_Reset_The_List_For_Partial_Sequence_Orders()
{
List<YamlPlayoutInstruction> firstGroup = CreateInstructions([1, 0], true, "fingerprint");
List<YamlPlayoutInstruction> secondGroup = CreateInstructions([0, 1], true, "fingerprint");
var savedDefinition = new YamlPlayoutDefinition
{
Playout = firstGroup.Concat(secondGroup).ToList()
};
var savedContext = new YamlPlayoutContext(new Playout(), savedDefinition, 1)
{
InstructionIndex = 2
};
savedContext.RestoreSequenceOrders();
YamlPlayoutContext.State savedState =
JsonConvert.DeserializeObject<YamlPlayoutContext.State>(savedContext.Serialize());
var partialOrders = new Dictionary<string, List<YamlPlayoutContext.SequenceOrder>>
{
[string.Empty] = [savedState.SequenceOrders[string.Empty][0]]
};
var anchor = new PlayoutAnchor
{
NextStart = DateTime.UtcNow,
Context = JsonConvert.SerializeObject(savedState with { SequenceOrders = partialOrders })
};
var restoredDefinition = new YamlPlayoutDefinition
{
Playout = CreateInstructions([0, 1], false, "fingerprint")
.Concat(CreateInstructions([0, 1], false, "fingerprint"))
.ToList()
};
var restoredContext = new YamlPlayoutContext(new Playout(), restoredDefinition, 1);
restoredContext.Reset(anchor, DateTimeOffset.Now);
restoredContext.RestoreSequenceOrders();
restoredContext.InstructionIndex.ShouldBe(0);
}
[Test]
public void Restoring_Should_Reset_Sequence_Orders_Without_A_List_Fingerprint()
{
YamlPlayoutDefinition definition = CreateDefinition([0, 1, 2, 3], [0, 1, 2, 3], false);
var context = new YamlPlayoutContext(new Playout(), definition, 1);
var incompleteState = new YamlPlayoutContext.State(
2,
1,
false,
[],
null,
SequenceOrders: new Dictionary<string, List<YamlPlayoutContext.SequenceOrder>>
{
[string.Empty] = [new YamlPlayoutContext.SequenceOrder("shows", [2, 0, 3, 1])]
});
var anchor = new PlayoutAnchor
{
NextStart = DateTime.UtcNow,
Context = JsonConvert.SerializeObject(incompleteState)
};
context.Reset(anchor, DateTimeOffset.Now);
context.RestoreSequenceOrders();
context.InstructionIndex.ShouldBe(0);
definition.Playout.Select(i => i.Content).ShouldBe(["show-0", "show-1", "show-2", "show-3"]);
}
[Test]
public void Restoring_Should_Reset_The_List_For_Malformed_Sequence_Orders()
{
YamlPlayoutDefinition definition = CreateDefinition([0, 1, 2, 3], [0, 1, 2, 3], false);
var context = new YamlPlayoutContext(new Playout(), definition, 1);
var malformedState = new YamlPlayoutContext.State(
2,
1,
false,
[],
null,
SequenceOrders: new Dictionary<string, List<YamlPlayoutContext.SequenceOrder>>
{
[string.Empty] = [new YamlPlayoutContext.SequenceOrder("shows", null)]
});
var anchor = new PlayoutAnchor
{
NextStart = DateTime.UtcNow,
Context = JsonConvert.SerializeObject(malformedState)
};
context.Reset(anchor, DateTimeOffset.Now);
Should.NotThrow(context.RestoreSequenceOrders);
context.InstructionIndex.ShouldBe(0);
}
[Test]
public void Restoring_Should_Accept_Anchors_Without_Sequence_Orders()
{
YamlPlayoutDefinition definition = CreateDefinition([0, 1, 2, 3], [0, 1, 2, 3], false);
var context = new YamlPlayoutContext(new Playout(), definition, 1);
var anchor = new PlayoutAnchor
{
NextStart = DateTime.UtcNow,
Context = "{\"InstructionIndex\":2,\"GuideGroup\":1,\"GuideGroupLocked\":false," +
"\"ChannelWatermarkIds\":[],\"ScheduleIndices\":{\"\":2}}"
};
context.Reset(anchor, DateTimeOffset.Now);
Should.NotThrow(context.RestoreSequenceOrders);
definition.Playout.Select(i => i.Content).ShouldBe(["show-0", "show-1", "show-2", "show-3"]);
context.InstructionIndex.ShouldBe(2);
}
[Test]
public void Restoring_Should_Ignore_A_Schedule_With_No_Name()
{
YamlPlayoutDefinition savedDefinition = CreateDefinition([2, 0, 3, 1], true, (null, [1, 0]));
var savedContext = new YamlPlayoutContext(new Playout(), savedDefinition, 1)
{
InstructionIndex = 3
};
savedContext.RestoreSequenceOrders();
var anchor = new PlayoutAnchor
{
NextStart = DateTime.UtcNow,
Context = savedContext.Serialize()
};
YamlPlayoutDefinition restoredDefinition = CreateDefinition([0, 1, 2, 3], false, (null, [0, 1]));
var restoredContext = new YamlPlayoutContext(new Playout(), restoredDefinition, 1);
restoredContext.Reset(anchor, DateTimeOffset.Now);
restoredContext.RestoreSequenceOrders();
restoredDefinition.Playout.Select(i => i.Content).ShouldBe(["show-2", "show-0", "show-3", "show-1"]);
restoredContext.InstructionIndex.ShouldBe(3);
}
[Test]
public void Restoring_Should_Ignore_A_Duplicate_Schedule_Name()
{
YamlPlayoutDefinition savedDefinition =
CreateDefinition([0, 1, 2, 3], true, ("Christmas", [2, 0, 3, 1]), ("Christmas", [1, 0]));
var savedContext = new YamlPlayoutContext(new Playout(), savedDefinition, 1);
savedContext.RestoreSequenceOrders();
savedContext.SwitchToSchedule("Christmas");
savedContext.InstructionIndex = 3;
var anchor = new PlayoutAnchor
{
NextStart = DateTime.UtcNow,
Context = savedContext.Serialize()
};
YamlPlayoutDefinition restoredDefinition =
CreateDefinition([0, 1, 2, 3], false, ("Christmas", [0, 1, 2, 3]), ("Christmas", [0, 1]));
var restoredContext = new YamlPlayoutContext(new Playout(), restoredDefinition, 1);
restoredContext.Reset(anchor, DateTimeOffset.Now);
restoredContext.RestoreSequenceOrders();
restoredDefinition.Schedules[0].Playout.Select(i => i.Content)
.ShouldBe(["show-2", "show-0", "show-3", "show-1"]);
restoredContext.InstructionIndex.ShouldBe(3);
}
private static YamlPlayoutDefinition CreateDefinition(
int[] defaultOrder,
bool shuffled,
params (string Name, int[] Order)[] schedules) =>
new()
{
Playout = CreateInstructions(defaultOrder, shuffled, "fingerprint"),
Schedules = schedules
.Select(s => new YamlPlayoutScheduleItem
{
Name = s.Name,
StartDate = "12-25",
EndDate = "12-25",
Playout = CreateInstructions(s.Order, shuffled, "fingerprint")
})
.ToList()
};
private static YamlPlayoutDefinition CreateDefinition(
int[] defaultOrder,
int[] scheduleOrder,
bool shuffled,
string fingerprint = "fingerprint")
{
return new YamlPlayoutDefinition
{
Playout = CreateInstructions(defaultOrder, shuffled, fingerprint),
Schedules =
[
new YamlPlayoutScheduleItem
{
Name = "Christmas",
StartDate = "12-25",
EndDate = "12-25",
Playout = CreateInstructions(scheduleOrder, shuffled, fingerprint)
}
]
};
}
private static List<YamlPlayoutInstruction> CreateInstructions(
int[] order,
bool shuffled,
string fingerprint)
{
var sequenceGuid = Guid.NewGuid();
return order
.Select(index => new YamlPlayoutInstruction
{
Content = $"show-{index}",
SequenceKey = "shows",
SequenceGuid = sequenceGuid,
SequenceIndex = index,
SequenceShuffled = shuffled,
SequenceFingerprint = fingerprint
})
.ToList();
}
}
}

34
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs

@ -5,6 +5,8 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; @@ -5,6 +5,8 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler
{
private const int MaxShuffleAttempts = 10;
public bool Reset => false;
public Task<bool> Handle(
@ -31,23 +33,33 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler @@ -31,23 +33,33 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler
List<YamlPlayoutInstruction> playout = context.CurrentInstructions;
var groupedSequenceItems = playout
.Where(i => i.SequenceKey == sequenceKey)
.GroupBy(i => i.SequenceGuid)
.Select((instruction, index) => new { Instruction = instruction, Index = index })
.Where(x => x.Instruction.SequenceKey == sequenceKey)
.GroupBy(x => x.Instruction.SequenceGuid)
.ToList();
foreach (IGrouping<Guid, YamlPlayoutInstruction> grouping in groupedSequenceItems)
foreach (var grouping in groupedSequenceItems)
{
// shuffle, avoiding starting with the tail of the last shuffle
YamlPlayoutInstruction tail = grouping.Last();
var shuffledGroup = grouping.OrderBy(_ => Guid.NewGuid()).ToList();
while (shuffledGroup.Count > 1 && shuffledGroup.Head() == tail)
var currentGroup = grouping.OrderBy(x => x.Index).ToList();
// shuffle, try to avoid starting with the tail of the last shuffle
YamlPlayoutInstruction tail = currentGroup.Last().Instruction;
var shuffledGroup = currentGroup.Select(x => x.Instruction).OrderBy(_ => Guid.NewGuid()).ToList();
var attempts = 0;
while (shuffledGroup.Count > 1
&& shuffledGroup.Head() == tail
&& attempts++ < MaxShuffleAttempts
&& !cancellationToken.IsCancellationRequested)
{
shuffledGroup = grouping.OrderBy(_ => Guid.NewGuid()).ToList();
shuffledGroup = currentGroup.Select(x => x.Instruction).OrderBy(_ => Guid.NewGuid()).ToList();
}
int firstIndex = playout.FindIndex(i => i.SequenceGuid == grouping.Key);
playout.RemoveRange(firstIndex, shuffledGroup.Count);
playout.InsertRange(firstIndex, shuffledGroup);
for (var index = 0; index < currentGroup.Count; index++)
{
shuffledGroup[index].SequenceShuffled = true;
playout[currentGroup[index].Index] = shuffledGroup[index];
}
}
return Task.FromResult(true);

11
ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs

@ -22,4 +22,15 @@ public class YamlPlayoutInstruction @@ -22,4 +22,15 @@ public class YamlPlayoutInstruction
[YamlIgnore]
public Guid SequenceGuid { get; set; }
[YamlIgnore]
public int SequenceIndex { get; set; }
[YamlIgnore]
public bool SequenceShuffled { get; set; }
[YamlIgnore]
public string SequenceFingerprint { get; set; }
public YamlPlayoutInstruction Clone() => (YamlPlayoutInstruction)MemberwiseClone();
}

26
ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Security.Cryptography;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Repositories;
@ -10,6 +12,7 @@ using ErsatzTV.Core.Scheduling.YamlScheduling.Models; @@ -10,6 +12,7 @@ using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
using ErsatzTV.Core.Search;
using LanguageExt.UnsafeValueAccess;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@ -272,6 +275,8 @@ public class SequentialPlayoutBuilder( @@ -272,6 +275,8 @@ public class SequentialPlayoutBuilder(
}
}
context.RestoreSequenceOrders();
// handle all playout instructions
while (context.CurrentTime < finish)
{
@ -434,7 +439,7 @@ public class SequentialPlayoutBuilder( @@ -434,7 +439,7 @@ public class SequentialPlayoutBuilder(
switch (instruction)
{
case YamlPlayoutSequenceInstruction sequenceInstruction:
IEnumerable<YamlPlayoutInstruction> sequenceInstructions = context.Definition.Sequence
List<YamlPlayoutInstruction> sequenceInstructions = context.Definition.Sequence
.Filter(s => s.Key == sequenceInstruction.Sequence)
.HeadOrNone()
.Map(s => s.Items)
@ -442,24 +447,29 @@ public class SequentialPlayoutBuilder( @@ -442,24 +447,29 @@ public class SequentialPlayoutBuilder(
.ToList();
var sequenceGuid = Guid.NewGuid();
string sequenceFingerprint = Convert.ToHexString(SHA256.HashData(
Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(sequenceInstructions, Formatting.None))));
int repeat = sequenceInstruction.Repeat > 0 ? sequenceInstruction.Repeat : 1;
for (var r = 0; r < repeat; r++)
{
// insert all instructions from the sequence
foreach (YamlPlayoutInstruction i in sequenceInstructions)
// insert independent instructions so each flattened sequence keeps its own shuffle state
for (var index = 0; index < sequenceInstructions.Count; index++)
{
// used for shuffling
i.SequenceKey = sequenceInstruction.Sequence;
i.SequenceGuid = sequenceGuid;
YamlPlayoutInstruction instructionCopy = sequenceInstructions[index].Clone();
instructionCopy.SequenceKey = sequenceInstruction.Sequence;
instructionCopy.SequenceGuid = sequenceGuid;
instructionCopy.SequenceIndex = r * sequenceInstructions.Count + index;
instructionCopy.SequenceShuffled = false;
instructionCopy.SequenceFingerprint = sequenceFingerprint;
// copy custom title
if (!string.IsNullOrWhiteSpace(sequenceInstruction.CustomTitle))
{
i.CustomTitle = sequenceInstruction.CustomTitle;
instructionCopy.CustomTitle = sequenceInstruction.CustomTitle;
}
playout.Add(i);
playout.Add(instructionCopy);
}
}

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

@ -1,3 +1,5 @@ @@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
@ -31,7 +33,11 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -31,7 +33,11 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
// saved state for each playout list (default keyed by empty string) so switching
// between schedules resumes each list's position and ambient modifiers cleanly
private readonly Dictionary<string, string> _listFingerprints = [];
private readonly Dictionary<string, ListState> _listStates = [];
private readonly System.Collections.Generic.HashSet<string> _staleListStates = [];
private Dictionary<string, string> _listFingerprintsToRestore;
private Dictionary<string, List<SequenceOrder>> _sequenceOrdersToRestore;
public Playout Playout { get; } = playout;
@ -60,6 +66,210 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -60,6 +66,210 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
public string ActiveSchedule => _activeSchedule;
public void RestoreSequenceOrders()
{
_listFingerprints.Clear();
_staleListStates.Clear();
foreach ((string listKey, List<YamlPlayoutInstruction> instructions) in GetInstructionLists())
{
string fingerprint = GetListFingerprint(instructions);
_listFingerprints[listKey] = fingerprint;
if (_listFingerprintsToRestore is not null &&
(!_listFingerprintsToRestore.TryGetValue(listKey, out string savedFingerprint) ||
!string.Equals(savedFingerprint, fingerprint, StringComparison.Ordinal)))
{
_staleListStates.Add(listKey);
ResetInstructionIndex(listKey);
}
}
if (_sequenceOrdersToRestore is not null)
{
if (_listFingerprintsToRestore is null)
{
foreach (string listKey in _sequenceOrdersToRestore.Keys)
{
_staleListStates.Add(listKey);
ResetInstructionIndex(listKey);
}
}
foreach ((string listKey, List<YamlPlayoutInstruction> instructions) in GetInstructionLists())
{
RestoreSequenceOrders(listKey, instructions);
}
}
_listFingerprintsToRestore = null;
_sequenceOrdersToRestore = null;
}
// only return first instance of name; ignore unnamed schedules
// this matches SwitchToSchedule (null is default, otherwise find first matching name)
private IEnumerable<(string ListKey, List<YamlPlayoutInstruction> Instructions)> GetInstructionLists()
{
yield return (string.Empty, Definition.Playout);
var seen = new System.Collections.Generic.HashSet<string>(StringComparer.Ordinal);
foreach (YamlPlayoutScheduleItem schedule in Definition.Schedules)
{
if (!string.IsNullOrWhiteSpace(schedule.Name) && seen.Add(schedule.Name))
{
yield return (schedule.Name, schedule.Playout);
}
}
}
private static string GetListFingerprint(List<YamlPlayoutInstruction> instructions)
{
var normalizedInstructions = instructions.ToList();
foreach (SequenceGroup sequenceGroup in GetSequenceGroups(instructions, false))
{
List<YamlPlayoutInstruction> declarationOrder = sequenceGroup.Items
.Select(x => x.Instruction)
.OrderBy(i => i.SequenceIndex)
.ToList();
for (var index = 0; index < sequenceGroup.Items.Count; index++)
{
normalizedInstructions[sequenceGroup.Items[index].Index] = declarationOrder[index];
}
}
IEnumerable<object> instructionState = normalizedInstructions.Select(instruction =>
string.IsNullOrWhiteSpace(instruction.SequenceKey)
? new
{
Kind = "instruction",
Instruction = JsonConvert.SerializeObject(instruction, Formatting.None)
}
: new
{
Kind = "sequence",
Instruction = JsonConvert.SerializeObject(new
{
Type = instruction.GetType().FullName,
instruction.SequenceKey,
instruction.SequenceFingerprint,
instruction.SequenceIndex,
instruction.CustomTitle
}, Formatting.None)
});
string serializedState = JsonConvert.SerializeObject(instructionState, Formatting.None);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(serializedState)));
}
private void RestoreSequenceOrders(string listKey, List<YamlPlayoutInstruction> instructions)
{
string normalizedListKey = listKey ?? string.Empty;
if (_staleListStates.Contains(normalizedListKey) ||
!_sequenceOrdersToRestore.TryGetValue(normalizedListKey, out List<SequenceOrder> savedOrders))
{
return;
}
if (savedOrders is null ||
savedOrders.Count == 0 ||
savedOrders.Any(o => o is null || string.IsNullOrWhiteSpace(o.Sequence) || o.Order is null))
{
ResetInstructionIndex(normalizedListKey);
return;
}
Dictionary<string, Queue<SequenceGroup>> groupsBySequence = GetSequenceGroups(instructions, false)
.GroupBy(g => g.Sequence)
.ToDictionary(g => g.Key, g => new Queue<SequenceGroup>(g));
var restorations = new List<(SequenceGroup Group, List<YamlPlayoutInstruction> Instructions)>();
foreach (SequenceOrder savedOrder in savedOrders)
{
if (!groupsBySequence.TryGetValue(savedOrder.Sequence, out Queue<SequenceGroup> groups) ||
!groups.TryDequeue(out SequenceGroup sequenceGroup) ||
!TryRestoreSequenceOrder(savedOrder, sequenceGroup, out List<YamlPlayoutInstruction> restoredItems))
{
ResetInstructionIndex(normalizedListKey);
return;
}
restorations.Add((sequenceGroup, restoredItems));
}
if (savedOrders.Select(o => o.Sequence).Distinct().Any(sequence => groupsBySequence[sequence].Count > 0))
{
ResetInstructionIndex(normalizedListKey);
return;
}
foreach ((SequenceGroup sequenceGroup, List<YamlPlayoutInstruction> restoredItems) in restorations)
{
for (var index = 0; index < sequenceGroup.Items.Count; index++)
{
restoredItems[index].SequenceShuffled = true;
instructions[sequenceGroup.Items[index].Index] = restoredItems[index];
}
}
}
private static bool TryRestoreSequenceOrder(
SequenceOrder savedOrder,
SequenceGroup sequenceGroup,
out List<YamlPlayoutInstruction> restoredItems)
{
restoredItems = [];
if (savedOrder.Order.Count != sequenceGroup.Items.Count)
{
return false;
}
Dictionary<int, Queue<YamlPlayoutInstruction>> instructionsByIndex = sequenceGroup.Items
.GroupBy(x => x.Instruction.SequenceIndex)
.ToDictionary(
g => g.Key,
g => new Queue<YamlPlayoutInstruction>(g.Select(x => x.Instruction)));
foreach (int sequenceIndex in savedOrder.Order)
{
if (!instructionsByIndex.TryGetValue(sequenceIndex, out Queue<YamlPlayoutInstruction> candidates) ||
!candidates.TryDequeue(out YamlPlayoutInstruction restoredInstruction))
{
restoredItems = [];
return false;
}
restoredItems.Add(restoredInstruction);
}
return instructionsByIndex.Values.All(q => q.Count == 0);
}
private void ResetInstructionIndex(string listKey)
{
if (string.Equals(listKey, _activeSchedule ?? string.Empty, StringComparison.Ordinal))
{
_instructionIndex = 0;
}
if (_listStates.TryGetValue(listKey, out ListState savedState))
{
_listStates[listKey] = savedState with { InstructionIndex = 0 };
}
}
private static List<SequenceGroup> GetSequenceGroups(
List<YamlPlayoutInstruction> instructions,
bool shuffledOnly) =>
instructions
.Select((instruction, index) => new IndexedInstruction(instruction, index))
.Where(x =>
!string.IsNullOrWhiteSpace(x.Instruction.SequenceKey) &&
(!shuffledOnly || x.Instruction.SequenceShuffled))
.GroupBy(x => x.Instruction.SequenceGuid)
.OrderBy(g => g.Min(x => x.Index))
.Select(g => new SequenceGroup(
g.First().Instruction.SequenceKey,
g.OrderBy(x => x.Index).ToList()))
.ToList();
// switch to the playout list for the given schedule (null => default playout)
public void SwitchToSchedule(string scheduleName)
{
@ -105,10 +315,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -105,10 +315,10 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
private ListState CaptureState() =>
new(
_instructionIndex,
[.._visitedInstructions],
[.._channelWatermarkIds],
[.. _visitedInstructions],
[.. _channelWatermarkIds],
new Dictionary<int, string>(_graphicsElements),
[.._fillerKind],
[.. _fillerKind],
_preRollSequence,
_postRollSequence,
_midRollSequence);
@ -117,7 +327,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -117,7 +327,7 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
{
_instructionIndex = state.InstructionIndex;
_visitedInstructions = [..state.VisitedInstructions];
_visitedInstructions = [.. state.VisitedInstructions];
_channelWatermarkIds.Clear();
foreach (int id in state.ChannelWatermarkIds)
@ -232,7 +442,9 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -232,7 +442,9 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
_channelWatermarkIds.ToList(),
preRollSequence,
_activeSchedule,
scheduleIndices);
scheduleIndices,
CaptureSequenceOrders(),
_listFingerprints.Count > 0 ? new Dictionary<string, string>(_listFingerprints) : null);
return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings);
}
@ -277,6 +489,9 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -277,6 +489,9 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
_preRollSequence = preRollSequence;
}
_listFingerprintsToRestore = state.ListFingerprints;
_sequenceOrdersToRestore = state.SequenceOrders;
// restore saved instruction indices for each playout list
if (state.ScheduleIndices is not null)
{
@ -306,6 +521,34 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -306,6 +521,34 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
}
}
private Dictionary<string, List<SequenceOrder>> CaptureSequenceOrders()
{
var result = new Dictionary<string, List<SequenceOrder>>();
foreach ((string listKey, List<YamlPlayoutInstruction> instructions) in GetInstructionLists())
{
CaptureSequenceOrders(result, listKey, instructions);
}
return result.Count > 0 ? result : null;
}
private static void CaptureSequenceOrders(
Dictionary<string, List<SequenceOrder>> result,
string listKey,
List<YamlPlayoutInstruction> instructions)
{
List<SequenceOrder> sequenceOrders = GetSequenceGroups(instructions, true)
.Select(g => new SequenceOrder(
g.Sequence,
g.Items.Select(x => x.Instruction.SequenceIndex).ToList()))
.ToList();
if (sequenceOrders.Count > 0)
{
result[listKey] = sequenceOrders;
}
}
public record State(
int? InstructionIndex,
int? GuideGroup,
@ -313,10 +556,18 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -313,10 +556,18 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
List<int> ChannelWatermarkIds,
string PreRollSequence,
string ActiveSchedule = null,
Dictionary<string, int> ScheduleIndices = null);
Dictionary<string, int> ScheduleIndices = null,
Dictionary<string, List<SequenceOrder>> SequenceOrders = null,
Dictionary<string, string> ListFingerprints = null);
public record SequenceOrder(string Sequence, List<int> Order);
public record MidRollSequence(string Sequence, string Expression);
private sealed record IndexedInstruction(YamlPlayoutInstruction Instruction, int Index);
private sealed record SequenceGroup(string Sequence, List<IndexedInstruction> Items);
// in-memory snapshot of a playout list's position and ambient modifier state,
// used to resume each list cleanly when switching between schedules during a build
private sealed record ListState(
@ -328,4 +579,4 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio @@ -328,4 +579,4 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
Option<string> PreRollSequence,
Option<string> PostRollSequence,
Option<MidRollSequence> MidRollSequence);
}
}

Loading…
Cancel
Save