diff --git a/CHANGELOG.md b/CHANGELOG.md index faa0fbbd3..3b2e7da62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Always randomize start points for all collections the first time they are used - Previously, only collections scheduled during the first day of a playout build had their start points randomized - Allow multiple ETV instances when multiple config folders are used +- Maintain collection progress when refreshing a classic playout + - Classic playouts save a checkpoint for each day they build, and refresh rewinds to the checkpoint for the current day + - Checkpoints were only saved when a build happened to stop on a day boundary, so playouts with long items (movies, long blocks) were often missing the checkpoint for the current day, and refreshing those restarted every collection from the beginning + - Checkpoints are now saved whenever a build crosses midnight - Fix green line sometimes seen with NVIDIA and AMD/VAAPI encoding - - Both bugs were in ffmpeg and ETV's patched ffmpeg 8.1.2 is required for the fixes + - Both bugs were in ffmpeg, and ETV's patched ffmpeg 8.1.2 is required for the fixes ## [26.6.0] - 2026-07-09 ### Added diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs index c3d9048b1..62c961230 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs @@ -166,7 +166,9 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase } playout.Anchor.NextStartOffset.ShouldBe(finish); - playout.ProgramScheduleAnchors.Count.ShouldBe(1); + + // the continue anchor, plus a checkpoint for the midnight this build ended on + playout.ProgramScheduleAnchors.Count.ShouldBe(2); playout.ProgramScheduleAnchors.Head().EnumeratorState.Index.ShouldBe(0); PlayoutProgramScheduleAnchor headAnchor = playout.ProgramScheduleAnchors.Head(); @@ -216,7 +218,8 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase playout.Anchor.NextStartOffset.ShouldBe(start + TimeSpan.FromHours(30)); - playout.ProgramScheduleAnchors.Count.ShouldBe(2); + // continue anchor, detractor checkpoint, and the checkpoint for the midnight the first build crossed + playout.ProgramScheduleAnchors.Count.ShouldBe(3); playout.ProgramScheduleAnchors.Head().EnumeratorState.Index.ShouldBe(1); // continue 1h later @@ -240,7 +243,8 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase playout.Anchor.NextStartOffset.ShouldBe(start + TimeSpan.FromHours(30)); - playout.ProgramScheduleAnchors.Count.ShouldBe(2); + // continue anchor, detractor checkpoint, and the checkpoint for the midnight the first build crossed + playout.ProgramScheduleAnchors.Count.ShouldBe(3); playout.ProgramScheduleAnchors.Head().EnumeratorState.Index.ShouldBe(1); } @@ -329,9 +333,10 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase result.AddedItems.Count.ShouldBe(53); } - playout.ProgramScheduleAnchors.Count.ShouldBe(2); + // a checkpoint for each of the two midnights the build crossed, plus the continue anchor + playout.ProgramScheduleAnchors.Count.ShouldBe(3); + playout.ProgramScheduleAnchors.Count(x => x.AnchorDate is not null).ShouldBe(2); - playout.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).ShouldBeTrue(); PlayoutProgramScheduleAnchor lastCheckpoint = playout.ProgramScheduleAnchors .OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue) .First(); @@ -339,11 +344,14 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase lastCheckpoint.EnumeratorState.Index.ShouldBe(3); // we need to mess up the ordering to trigger the problematic behavior - // this simulates the way the rows are loaded with EF - PlayoutProgramScheduleAnchor oldest = playout.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate) - .Last(); - PlayoutProgramScheduleAnchor newest = playout.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate) - .First(); + // this simulates the way the rows are loaded with EF, with only the checkpoints kept + var checkpoints = playout.ProgramScheduleAnchors + .Filter(a => a.AnchorDate is not null) + .OrderBy(a => a.AnchorDate) + .ToList(); + + PlayoutProgramScheduleAnchor oldest = checkpoints.Head(); + PlayoutProgramScheduleAnchor newest = checkpoints.Last(); playout.ProgramScheduleAnchors = [ @@ -460,9 +468,10 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase result.AddedItems.Count.ShouldBe(53); } - playout.ProgramScheduleAnchors.Count.ShouldBe(4); - - playout.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).ShouldBeTrue(); + // two collections, each with a checkpoint for the two midnights the build crossed + // plus a continue anchor + playout.ProgramScheduleAnchors.Count.ShouldBe(6); + playout.ProgramScheduleAnchors.Count(x => x.AnchorDate is not null).ShouldBe(4); PlayoutProgramScheduleAnchor lastCheckpoint = playout.ProgramScheduleAnchors .Filter(psa => psa.SmartCollectionId == 1) .OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue) diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs new file mode 100644 index 000000000..b7142c411 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs @@ -0,0 +1,279 @@ +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.Tests.Fakes; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; + +namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; + +/// +/// Unit-test version of the "fast forward a playout" debugging endpoint that used to live in +/// ChannelController: run hourly continue builds across several days and assert invariants at +/// every step. +/// +[TestFixture] +public class MultiDayBuildTests : PlayoutBuilderTestBase +{ + private const int DaysToBuild = 2; + private const int WeekOfDaysToBuild = 7; + + [Test] + public async Task Continue_Should_Keep_A_Checkpoint_For_The_Current_Day() + { + // 6 hour movies scheduled 3 at a time, so a block spans 18 hours and regularly + // crosses midnight - which is expected and intended + (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( + TimeSpan.FromHours(6), + multipleCount: "3"); + + // a playout created at 20:00, then built every hour for a week + DateTimeOffset start = LocalTime(20); + var missing = new List(); + + for (var hour = 0; hour < 24 * 7; hour++) + { + DateTimeOffset now = start.AddHours(hour); + + Either result = await builder.Build( + now, + playout, + referenceData, + PlayoutBuildMode.Continue, + CancellationToken); + + result.IsRight.ShouldBeTrue($"build failed at {now:yyyy-MM-dd HH:mm}"); + + // a build never writes a checkpoint for its own first day, and doesn't need to: + // there is no earlier state to restore, so refreshing starts from index 0 and + // reproduces the same schedule + if (now.Date == start.Date) + { + continue; + } + + // from here on, a refresh has to have somewhere to rewind to + bool hasCheckpointForToday = playout.ProgramScheduleAnchors.Any(a => + a.AnchorDateOffset.HasValue && a.AnchorDateOffset.Value.Date == now.Date); + + if (!hasCheckpointForToday) + { + missing.Add($"{now:yyyy-MM-dd HH:mm}"); + } + } + + missing.ShouldBeEmpty( + $"[tz {TimeZoneInfo.Local.Id}] no checkpoint for the current day at {missing.Count} hour(s), " + + $"first: {missing.FirstOrDefault()}"); + } + + [Test] + public async Task Refresh_Should_Not_Reset_Collection_Progress() + { + // a collection large enough that the index can't wrap over the whole run, so the + // index is a straightforward measure of how much progress has been made + (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( + TimeSpan.FromHours(6), + multipleCount: "3", + itemCount: 300); + + DateTimeOffset start = LocalTime(20); + + // three weeks of hourly builds, so that progress is far enough along to be clearly + // distinguishable from a playout that restarted at the beginning + for (var hour = 0; hour < 24 * 21; hour++) + { + await builder.Build( + start.AddHours(hour), + playout, + referenceData, + PlayoutBuildMode.Continue, + CancellationToken); + } + + int before = HeadIndex(playout); + before.ShouldBeGreaterThan(0, "the fast forward should have consumed some of the collection"); + + Either result = await builder.Build( + start.AddDays(21), + playout, + referenceData, + PlayoutBuildMode.Refresh, + CancellationToken); + + result.IsRight.ShouldBeTrue(); + + // a refresh rewinds to the start of today, so the index legitimately moves back by + // up to a day. a refresh that lost its checkpoints restarts at 0 and only advances + // through the build window, landing an order of magnitude lower + int after = HeadIndex(playout); + after.ShouldBeGreaterThan( + before / 2, + $"[tz {TimeZoneInfo.Local.Id}] refresh reset collection progress (was {before}, now {after})"); + } + + [Test] + public async Task Continue_Should_Keep_A_Checkpoint_For_Every_Day_In_The_Build_Window() + { + // shaped like the playouts that were missing checkpoints in a real user database: a week + // long build window, shuffled schedule items, and blocks of five ~100 minute items + (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( + TimeSpan.FromMinutes(100), + multipleCount: "5", + itemCount: 300, + daysToBuild: WeekOfDaysToBuild, + shuffleScheduleItems: true, + scheduleItemCount: 3); + + DateTimeOffset start = LocalTime(20); + var missing = new List(); + + for (var hour = 0; hour < 24 * 21; hour++) + { + DateTimeOffset now = start.AddHours(hour); + + Either result = await builder.Build( + now, + playout, + referenceData, + PlayoutBuildMode.Continue, + CancellationToken); + + result.IsRight.ShouldBeTrue($"build failed at {now:yyyy-MM-dd HH:mm}"); + + // every day the playout covers needs a checkpoint, so that a refresh on any of them has + // somewhere to rewind to. the build's own first day is the one exception, and days + // already in the past are pruned + DateTime firstExpected = now.Date > start.Date ? now.Date : start.Date.AddDays(1); + DateTime lastExpected = now.AddDays(WeekOfDaysToBuild).Date; + + for (DateTime date = firstExpected; date <= lastExpected; date = date.AddDays(1)) + { + bool hasCheckpoint = playout.ProgramScheduleAnchors.Any(a => + a.AnchorDateOffset.HasValue && a.AnchorDateOffset.Value.Date == date); + + if (!hasCheckpoint) + { + missing.Add($"{date:yyyy-MM-dd} (at {now:MM-dd HH:mm})"); + } + } + } + + missing.ShouldBeEmpty( + $"[tz {TimeZoneInfo.Local.Id}] {missing.Count} day(s) in the build window had no checkpoint, " + + $"first: {missing.FirstOrDefault()}"); + } + + private static int HeadIndex(Playout playout) => + playout.ProgramScheduleAnchors + .Filter(a => a.AnchorDate is null) + .Map(a => a.EnumeratorState.Index) + .HeadOrNone() + .IfNone(-1); + + private (PlayoutBuilder, Playout, PlayoutReferenceData) MultipleTestData( + TimeSpan itemDuration, + string multipleCount, + int itemCount = 5, + int daysToBuild = DaysToBuild, + bool shuffleScheduleItems = false, + int scheduleItemCount = 1) + { + var collection = new Collection + { + Id = 1, + Name = "Test Collection", + MediaItems = Enumerable.Range(1, itemCount) + .Map(i => (MediaItem)TestMovie(i, itemDuration, new DateTime(2000 + i, 1, 1))) + .ToList() + }; + + var collectionRepo = new FakeMediaCollectionRepository(Map((collection.Id, collection.MediaItems.ToList()))); + + IConfigElementRepository configRepo = Substitute.For(); + + // ConfigElementKey has no value equality and hands out a new instance on every access, so + // the key has to be matched on its string, not with Arg.Is(theKey) + configRepo + .GetValue( + Arg.Is(k => k.Key == ConfigElementKey.PlayoutDaysToBuild.Key), + Arg.Any()) + .Returns(Some(daysToBuild)); + + var televisionRepo = new FakeTelevisionRepository(); + IArtistRepository artistRepo = Substitute.For(); + IMultiEpisodeShuffleCollectionEnumeratorFactory factory = + Substitute.For(); + IRerunHelper rerunHelper = Substitute.For(); + + var builder = new PlayoutBuilder( + configRepo, + collectionRepo, + televisionRepo, + artistRepo, + factory, + new MockFileSystem(), + rerunHelper, + Logger); + + var items = Enumerable.Range(1, scheduleItemCount) + .Map(i => (ProgramScheduleItem)new ProgramScheduleItemMultiple + { + Id = i, + Index = i, + CollectionType = CollectionType.Collection, + Collection = collection, + CollectionId = collection.Id, + StartTime = null, + PlaybackOrder = PlaybackOrder.Chronological, + Count = multipleCount + }) + .ToList(); + + var playout = new Playout + { + Id = 1, + ScheduleKind = PlayoutScheduleKind.Classic, + ProgramSchedule = new ProgramSchedule + { + Items = items, + ShuffleScheduleItems = shuffleScheduleItems + }, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, + Items = [], + ProgramScheduleAnchors = [], + ProgramScheduleAlternates = [], + FillGroupIndices = [] + }; + + var referenceData = new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); + + return (builder, playout, referenceData); + } + + /// + /// Anchor dates are compared in machine-local time + /// (), so the test clock has to be local + /// too, or day boundaries won't line up. That means these tests run in whatever zone the machine is + /// in - UTC on CI, something else locally - so the window below is deliberately placed in mid-June, + /// where no zone has a DST transition. Day-boundary behaviour across a DST change is a separate + /// concern and wants its own test. + /// + private static DateTimeOffset LocalTime(int hour) + { + var date = new DateTime(2025, 6, 10, 0, 0, 0, DateTimeKind.Unspecified); + return new DateTimeOffset(date, TimeZoneInfo.Local.GetUtcOffset(date)) + TimeSpan.FromHours(hour); + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs index bf3a4d225..78b553382 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs @@ -2119,9 +2119,15 @@ public class NewPlayoutTests : PlayoutBuilderTestBase result.AddedItems[7].FinishOffset.ShouldBe(start + TimeSpan.FromHours(48)); } - playout.ProgramScheduleAnchors.Count.ShouldBe(2); - playout.ProgramScheduleAnchors.Count(a => a.EnumeratorState.Index == 4 % 3).ShouldBe(1); - playout.ProgramScheduleAnchors.Count(a => a.EnumeratorState.Index == 8 % 3).ShouldBe(1); + // a checkpoint for each midnight the two day build crossed, plus the continue anchor + var checkpoints = playout.ProgramScheduleAnchors.Filter(a => a.AnchorDate is not null).ToList(); + checkpoints.Count.ShouldBe(2); + checkpoints.Count(a => a.EnumeratorState.Index == 4 % 3).ShouldBe(1); + checkpoints.Count(a => a.EnumeratorState.Index == 8 % 3).ShouldBe(1); + + PlayoutProgramScheduleAnchor continueAnchor = + playout.ProgramScheduleAnchors.Single(a => a.AnchorDate is null); + continueAnchor.EnumeratorState.Index.ShouldBe(8 % 3); int seed = playout.ProgramScheduleAnchors[0].EnumeratorState.Seed; playout.ProgramScheduleAnchors.All(a => a.EnumeratorState.Seed == seed).ShouldBeTrue(); diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index b60b3ac83..10c5c202b 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -169,7 +169,9 @@ public class PlayoutBuilder : IPlayoutBuilder // _logger.LogDebug("All anchors: {@Anchors}", playout.ProgramScheduleAnchors); - // remove null anchor date ("continue" anchors) + // remove the "continue" anchors (null anchor date), which hold the state at the head of + // the playout; they have to go rather than just be ignored, because GetMediaCollectionEnumerator + // ranks a null anchor date above every checkpoint and we want to rewind to today's checkpoint playout.ProgramScheduleAnchors.RemoveAll(a => a.AnchorDate is null); // _logger.LogDebug("Checkpoint anchors: {@Anchors}", playout.ProgramScheduleAnchors); @@ -455,7 +457,8 @@ public class PlayoutBuilder : IPlayoutBuilder return result; } - // build each day with "continue" anchors + // build one whole day at a time; this is also how alternate schedules switch per day, + // since the active schedule is selected once per pass while (finish < playoutFinish) { if (cancellationToken.IsCancellationRequested) @@ -464,14 +467,13 @@ public class PlayoutBuilder : IPlayoutBuilder } _logger.LogDebug("Building playout from {Start} to {Finish}", start, finish); - Either buildResult = await BuildPlayoutItems( + Either buildResult = await BuildPlayoutItemsForPass( playout, referenceData, result, start, finish, collectionMediaItems, - true, cancellationToken); foreach (BaseError error in buildResult.LeftToSeq()) @@ -495,16 +497,15 @@ public class PlayoutBuilder : IPlayoutBuilder if (start < playoutFinish) { - // build one final time without continue anchors + // build the remaining partial day _logger.LogDebug("Building final playout from {Start} to {Finish}", start, playoutFinish); - Either buildResult = await BuildPlayoutItems( + Either buildResult = await BuildPlayoutItemsForPass( playout, referenceData, result, start, playoutFinish, collectionMediaItems, - false, cancellationToken); foreach (BaseError error in buildResult.LeftToSeq()) @@ -548,14 +549,13 @@ public class PlayoutBuilder : IPlayoutBuilder return result; } - private async Task> BuildPlayoutItems( + private async Task> BuildPlayoutItemsForPass( Playout playout, PlayoutReferenceData referenceData, PlayoutBuildResult result, DateTimeOffset playoutStart, DateTimeOffset playoutFinish, Map> collectionMediaItems, - bool saveAnchorDate, CancellationToken cancellationToken) { var random = new Random(playout.Seed); @@ -901,6 +901,15 @@ public class PlayoutBuilder : IPlayoutBuilder result.AddedItems.AddRange(playoutItems); + // save a checkpoint anchor whenever scheduling crosses a local midnight; this has to + // happen here rather than at the end of a pass, because pass boundaries only + // aspirationally line up with midnights - the final partial pass never ends on one, and + // a long item can carry a whole-day pass well past its own boundary + if (nextState.CurrentTime.ToLocalTime().Date > playoutBuilderState.CurrentTime.ToLocalTime().Date) + { + SaveCheckpointAnchors(playout, collectionEnumerators, nextState.CurrentTime); + } + playoutBuilderState = nextState; } @@ -997,7 +1006,7 @@ public class PlayoutBuilder : IPlayoutBuilder } // build program schedule anchors - playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators, saveAnchorDate); + playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators); // build fill group indices playout.FillGroupIndices = BuildFillGroupIndices(playout, scheduleItemsFillGroupEnumerators); @@ -1192,26 +1201,82 @@ public class PlayoutBuilder : IPlayoutBuilder } }); - private static List BuildProgramScheduleAnchors( + private static bool AnchorMatchesCollectionKey(PlayoutProgramScheduleAnchor anchor, CollectionKey collectionKey) => + anchor.CollectionType == collectionKey.CollectionType + && anchor.CollectionId == collectionKey.CollectionId + && anchor.MediaItemId == collectionKey.MediaItemId + && anchor.FakeCollectionKey == collectionKey.FakeCollectionKey + && anchor.SmartCollectionId == collectionKey.SmartCollectionId + && anchor.RerunCollectionId == collectionKey.RerunCollectionId + && anchor.MultiCollectionId == collectionKey.MultiCollectionId + && anchor.PlaylistId == collectionKey.PlaylistId + && anchor.SearchQuery == collectionKey.SearchQuery; + + /// + /// Records the collection progress as of , which is the first + /// resumable point at or after a local midnight. Refresh rewinds to the checkpoint dated today, + /// so every local day of the playout needs one. + /// + private static void SaveCheckpointAnchors( Playout playout, Dictionary collectionEnumerators, - bool saveAnchorDate) + DateTimeOffset checkpointTime) + { + // anchor dates are compared as local dates, the same way AnchorDateOffset exposes them + DateTime checkpointDate = checkpointTime.ToLocalTime().Date; + + foreach ((CollectionKey collectionKey, IMediaCollectionEnumerator enumerator) in collectionEnumerators) + { + // one checkpoint per collection key per local date; rebuilding a day that already has a + // checkpoint replaces it, since the new state is the one that matches the new items + Option maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault(a => + AnchorMatchesCollectionKey(a, collectionKey) + && a.AnchorDateOffset.HasValue + && a.AnchorDateOffset.Value.Date == checkpointDate); + + // the enumerator keeps mutating its own state as the build continues, so the checkpoint + // needs a copy of it, frozen at this instant + CollectionEnumeratorState state = enumerator.State.Clone(); + + foreach (PlayoutProgramScheduleAnchor existing in maybeExisting) + { + existing.AnchorDate = checkpointTime.UtcDateTime; + existing.EnumeratorState = state; + } + + if (maybeExisting.IsNone) + { + playout.ProgramScheduleAnchors.Add( + new PlayoutProgramScheduleAnchor + { + Playout = playout, + PlayoutId = playout.Id, + CollectionType = collectionKey.CollectionType, + CollectionId = collectionKey.CollectionId, + MultiCollectionId = collectionKey.MultiCollectionId, + SmartCollectionId = collectionKey.SmartCollectionId, + RerunCollectionId = collectionKey.RerunCollectionId, + MediaItemId = collectionKey.MediaItemId, + PlaylistId = collectionKey.PlaylistId, + SearchQuery = collectionKey.SearchQuery, + FakeCollectionKey = collectionKey.FakeCollectionKey, + AnchorDate = checkpointTime.UtcDateTime, + EnumeratorState = state + }); + } + } + } + + private static List BuildProgramScheduleAnchors( + Playout playout, + Dictionary collectionEnumerators) { var result = new List(); foreach (CollectionKey collectionKey in collectionEnumerators.Keys) { Option maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault(a => - a.CollectionType == collectionKey.CollectionType - && a.CollectionId == collectionKey.CollectionId - && a.MediaItemId == collectionKey.MediaItemId - && a.FakeCollectionKey == collectionKey.FakeCollectionKey - && a.SmartCollectionId == collectionKey.SmartCollectionId - && a.RerunCollectionId == collectionKey.RerunCollectionId - && a.MultiCollectionId == collectionKey.MultiCollectionId - && a.PlaylistId == collectionKey.PlaylistId - && a.SearchQuery == collectionKey.SearchQuery - && a.AnchorDate is null); + AnchorMatchesCollectionKey(a, collectionKey) && a.AnchorDate is null); var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State); @@ -1237,14 +1302,10 @@ public class PlayoutBuilder : IPlayoutBuilder EnumeratorState = maybeEnumeratorState[collectionKey] }); - if (saveAnchorDate) - { - scheduleAnchor.AnchorDate = playout.Anchor?.NextStart; - } - result.Add(scheduleAnchor); } + // checkpoints are written by SaveCheckpointAnchors during the build; keep them all foreach (PlayoutProgramScheduleAnchor checkpointAnchor in playout.ProgramScheduleAnchors.Where(a => a.AnchorDate is not null)) {