diff --git a/CHANGELOG.md b/CHANGELOG.md index 3054cc35a..fc9e2a20d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutShuffleTests.cs b/ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutShuffleTests.cs new file mode 100644 index 000000000..96c546b37 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/SequentialPlayoutShuffleTests.cs @@ -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.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 handle = Task.Run(() => handler.Handle( + new YamlPlayoutContext(new Playout(), definition, 1), + shuffle, + PlayoutBuildMode.Reset, + _ => Task.CompletedTask, + NullLogger.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 handle = Task.Run(() => handler.Handle( + new YamlPlayoutContext(new Playout(), definition, 1), + shuffle, + PlayoutBuildMode.Reset, + _ => Task.CompletedTask, + NullLogger.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(); + config + .GetValue(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any()) + .Returns(Some(2)); + + Dictionary 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(); + media + .GetSmartCollectionItemsByName(Arg.Any(), Arg.Any()) + .Returns(call => Task.FromResult(new List { mediaItems[(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 = "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(playout.Anchor.Context); + savedState.InstructionIndex.ShouldBe(9); + savedState.SequenceOrders.ShouldContainKey(string.Empty); + List 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(playout.Anchor.Context); + List 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 declarationOrder = Enumerable.Range(1, SequenceLength).ToList(); + List 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 savedOrder) + { + List 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 Build( + SequentialPlayoutBuilder builder, + DateTimeOffset start, + Playout playout, + Channel channel, + List existingItems, + List history, + PlayoutBuildMode mode, + CancellationToken cancellationToken) + { + var referenceData = new PlayoutReferenceData( + channel, + Option.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(); + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs index 107fdd4e4..66157b7a7 100644 --- a/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutContextTests.cs @@ -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 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 firstGroup = CreateInstructions([1, 0], true, "fingerprint"); + List 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(savedContext.Serialize()); + var partialOrders = new Dictionary> + { + [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.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.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 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(); + } + } } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs index b0fab7f7a..ce58a3e52 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs @@ -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 Handle( @@ -31,23 +33,33 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler List 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 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); diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs index 0ef4aed13..024b9f140 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPlayoutInstruction.cs @@ -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(); } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs index 97e1f8bac..4e680a19e 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs @@ -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; 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( } } + context.RestoreSequenceOrders(); + // handle all playout instructions while (context.CurrentTime < finish) { @@ -434,7 +439,7 @@ public class SequentialPlayoutBuilder( switch (instruction) { case YamlPlayoutSequenceInstruction sequenceInstruction: - IEnumerable sequenceInstructions = context.Definition.Sequence + List sequenceInstructions = context.Definition.Sequence .Filter(s => s.Key == sequenceInstruction.Sequence) .HeadOrNone() .Map(s => s.Items) @@ -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); } } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs index c0cdb3e12..e2737d26d 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs @@ -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 // 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 _listFingerprints = []; private readonly Dictionary _listStates = []; + private readonly System.Collections.Generic.HashSet _staleListStates = []; + private Dictionary _listFingerprintsToRestore; + private Dictionary> _sequenceOrdersToRestore; public Playout Playout { get; } = playout; @@ -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 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 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 Instructions)> GetInstructionLists() + { + yield return (string.Empty, Definition.Playout); + + var seen = new System.Collections.Generic.HashSet(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 instructions) + { + var normalizedInstructions = instructions.ToList(); + foreach (SequenceGroup sequenceGroup in GetSequenceGroups(instructions, false)) + { + List 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 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 instructions) + { + string normalizedListKey = listKey ?? string.Empty; + if (_staleListStates.Contains(normalizedListKey) || + !_sequenceOrdersToRestore.TryGetValue(normalizedListKey, out List 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> groupsBySequence = GetSequenceGroups(instructions, false) + .GroupBy(g => g.Sequence) + .ToDictionary(g => g.Key, g => new Queue(g)); + var restorations = new List<(SequenceGroup Group, List Instructions)>(); + + foreach (SequenceOrder savedOrder in savedOrders) + { + if (!groupsBySequence.TryGetValue(savedOrder.Sequence, out Queue groups) || + !groups.TryDequeue(out SequenceGroup sequenceGroup) || + !TryRestoreSequenceOrder(savedOrder, sequenceGroup, out List 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 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 restoredItems) + { + restoredItems = []; + if (savedOrder.Order.Count != sequenceGroup.Items.Count) + { + return false; + } + + Dictionary> instructionsByIndex = sequenceGroup.Items + .GroupBy(x => x.Instruction.SequenceIndex) + .ToDictionary( + g => g.Key, + g => new Queue(g.Select(x => x.Instruction))); + + foreach (int sequenceIndex in savedOrder.Order) + { + if (!instructionsByIndex.TryGetValue(sequenceIndex, out Queue 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 GetSequenceGroups( + List 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 private ListState CaptureState() => new( _instructionIndex, - [.._visitedInstructions], - [.._channelWatermarkIds], + [.. _visitedInstructions], + [.. _channelWatermarkIds], new Dictionary(_graphicsElements), - [.._fillerKind], + [.. _fillerKind], _preRollSequence, _postRollSequence, _midRollSequence); @@ -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 _channelWatermarkIds.ToList(), preRollSequence, _activeSchedule, - scheduleIndices); + scheduleIndices, + CaptureSequenceOrders(), + _listFingerprints.Count > 0 ? new Dictionary(_listFingerprints) : null); return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings); } @@ -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 } } + private Dictionary> CaptureSequenceOrders() + { + var result = new Dictionary>(); + foreach ((string listKey, List instructions) in GetInstructionLists()) + { + CaptureSequenceOrders(result, listKey, instructions); + } + + return result.Count > 0 ? result : null; + } + + private static void CaptureSequenceOrders( + Dictionary> result, + string listKey, + List instructions) + { + List 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 List ChannelWatermarkIds, string PreRollSequence, string ActiveSchedule = null, - Dictionary ScheduleIndices = null); + Dictionary ScheduleIndices = null, + Dictionary> SequenceOrders = null, + Dictionary ListFingerprints = null); + + public record SequenceOrder(string Sequence, List Order); public record MidRollSequence(string Sequence, string Expression); + private sealed record IndexedInstruction(YamlPlayoutInstruction Instruction, int Index); + + private sealed record SequenceGroup(string Sequence, List 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 Option PreRollSequence, Option PostRollSequence, Option MidRollSequence); -} \ No newline at end of file +}