From 16b715699649fa787bbf0aa033a6139f2a44c7b6 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:51:34 -0500 Subject: [PATCH] fix: playlist enumerator consistency fixes (#2956) * fix: playlist enumerator consistency fixes * cleanup --- CHANGELOG.md | 10 + .../AlternateScheduleAnchorTests.cs | 19 +- .../ClassicScheduling/ContinuePlayoutTests.cs | 3 +- .../ClassicScheduling/MultiDayBuildTests.cs | 46 ++--- .../Scheduling/MarathonEnumeratorTests.cs | 175 ++++++++++++++++++ .../Scheduling/PlaylistEnumeratorTests.cs | 67 +++++++ .../Scheduling/ShuffledContentTests.cs | 7 +- .../Scheduling/PlaylistEnumerator.cs | 117 +++++++++--- ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 28 +-- .../SeasonEpisodeMediaCollectionEnumerator.cs | 7 +- 10 files changed, 381 insertions(+), 98 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Scheduling/MarathonEnumeratorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7c0f345..545587b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix case where shuffled collection progress would reset early - This only happened when also grouping episodes (e.g. `Keep Multi-Part Episodes Together`) - More groups made it more likely to happen +- Fix marathons and playlists that would never pick a new order + - `Marathon Shuffle Groups` and `Shuffle Playlist Items` choose a new order every time everything has played once + - Content that can never play was still counted as waiting to play, so that never happened and the order stayed the same forever + - This was caused by specials (season 0), which are skipped by `Season, Episode` order; marathons grouped by show or season use that order unless `Marathon Shuffle Items` is enabled + - Affected playouts also became slower to build the longer they ran +- Fix shuffled playlists and marathon groups changing order when a playout is rebuilt + - Existing playlists and marathons will change order once after updating +- Fix playlists used as filler skipping content + - When something doesn't fit, ETV puts the filler back where it was and tries again, but playlists were only partly put back + - This could skip playlist content, and could cause the next playout build to resume in the wrong place - 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 diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/AlternateScheduleAnchorTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/AlternateScheduleAnchorTests.cs index df6513926..6091bce38 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/AlternateScheduleAnchorTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/AlternateScheduleAnchorTests.cs @@ -11,24 +11,16 @@ using Testably.Abstractions.Testing; namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; -/// -/// A collection used only by an alternate schedule still gets an enumerator on days when that -/// alternate isn't active, because the collection media items are gathered once for the primary -/// schedule and every alternate. On those days it falls into the filler branch and gets a -/// shuffled enumerator, whose index domain is the grouped item count rather than the media item -/// count. -/// +// a collection used only by an alternate schedule still gets an enumerator on days that alternate +// isn't active, because media items are gathered once for the primary schedule and every alternate [TestFixture] public class AlternateScheduleAnchorTests : PlayoutBuilderTestBase { private const int PrimaryCollectionId = 1; private const int AlternateCollectionId = 2; - /// - /// The alternate's collection has 10 episodes and its schedule orders them by season/episode, - /// so its own enumerator's index domain is 0-9. With a multi-part pair the shuffled enumerator - /// used on inactive days groups those 10 items into 9, so index 9 is out of its domain. - /// + // season/episode order over 10 episodes indexes 0-9; with a multi-part pair, the shuffled + // enumerator used on inactive days sees 9 groups, so index 9 is outside its domain [TestCase(9, true, ExpectedResult = 9, TestName = "Index at the grouped count")] [TestCase(8, true, ExpectedResult = 8, TestName = "Index below the grouped count")] [TestCase(9, false, ExpectedResult = 9, TestName = "Index at the grouped count, nothing grouped")] @@ -47,8 +39,7 @@ public class AlternateScheduleAnchorTests : PlayoutBuilderTestBase EnumeratorState = new CollectionEnumeratorState { Seed = 1234, Index = index } }); - // a Wednesday, so the alternate (Sundays only) is not active and the alternate's - // collection is not scheduled at all + // a Wednesday, so the alternate (Sundays only) is not active DateTimeOffset now = LocalTime(new DateTime(2025, 6, 11), 20); now.DayOfWeek.ShouldBe(DayOfWeek.Wednesday); diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs index 62c961230..2586e3a6d 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs @@ -468,8 +468,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase result.AddedItems.Count.ShouldBe(53); } - // two collections, each with a checkpoint for the two midnights the build crossed - // plus a continue anchor + // two collections, each with a continue anchor and a checkpoint per midnight crossed playout.ProgramScheduleAnchors.Count.ShouldBe(6); playout.ProgramScheduleAnchors.Count(x => x.AnchorDate is not null).ShouldBe(4); PlayoutProgramScheduleAnchor lastCheckpoint = playout.ProgramScheduleAnchors diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs index b7142c411..efbc73ad5 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs @@ -11,11 +11,8 @@ 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. -/// +// unit-test version of the "fast forward a playout" debugging endpoint that used to live in +// ChannelController [TestFixture] public class MultiDayBuildTests : PlayoutBuilderTestBase { @@ -25,13 +22,11 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase [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 + // 6 hour movies 3 at a time, so an 18 hour block regularly crosses midnight (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(); @@ -48,15 +43,13 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase 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 + // no checkpoint is written for a build's own first day, and none is needed: there is no + // earlier state, so refreshing from index 0 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); @@ -74,8 +67,7 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase [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 + // large enough that the index can't wrap, so it measures progress directly (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( TimeSpan.FromHours(6), multipleCount: "3", @@ -83,8 +75,7 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase 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 + // long enough that progress is clearly distinguishable from a playout that restarted for (var hour = 0; hour < 24 * 21; hour++) { await builder.Build( @@ -107,9 +98,8 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase 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 + // rewinding to the start of today legitimately moves the index back by up to a day; a refresh + // that lost its checkpoints lands an order of magnitude lower int after = HeadIndex(playout); after.ShouldBeGreaterThan( before / 2, @@ -119,8 +109,7 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase [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 + // shaped like the playouts that were missing checkpoints in a real user database (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( TimeSpan.FromMinutes(100), multipleCount: "5", @@ -145,9 +134,7 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase 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 + // every day covered needs a checkpoint, except the build's own first day; past days are pruned DateTime firstExpected = now.Date > start.Date ? now.Date : start.Date.AddDays(1); DateTime lastExpected = now.AddDays(WeekOfDaysToBuild).Date; @@ -263,14 +250,9 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase 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. - /// + // 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 running in whatever zone the machine is in, hence mid-June, + // where no zone has a DST transition private static DateTimeOffset LocalTime(int hour) { var date = new DateTime(2025, 6, 10, 0, 0, 0, DateTimeKind.Unspecified); diff --git a/ErsatzTV.Core.Tests/Scheduling/MarathonEnumeratorTests.cs b/ErsatzTV.Core.Tests/Scheduling/MarathonEnumeratorTests.cs new file mode 100644 index 000000000..7c9d30358 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/MarathonEnumeratorTests.cs @@ -0,0 +1,175 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Scheduling.Engine; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Core.Tests.Scheduling; + +// marathon content builds a PlaylistEnumerator over one playlist item per group, so it inherits that +// enumerator's save/restore behaviour; these cover the state handling, not the grouping +[TestFixture] +public class MarathonEnumeratorTests +{ + private const int Seed = 987654321; + + [SetUp] + public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token; + + private CancellationToken _cancellationToken; + + // a cycle completes when every media item has been played, which resets the index to 0 + [Test] + public async Task Marathon_Should_Complete_A_Cycle() + { + List mediaItems = + [ + .. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10), + .. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20) + ]; + + PlaylistEnumerator enumerator = await CreateMarathon(mediaItems); + + List indexes = Advance(enumerator, 12); + + indexes.ShouldContain(0, "the index never returned to 0, so the cycle never completed"); + } + + // season 0 is filtered out by the season/episode enumerator but still counted as an item the cycle + // is waiting on, so the cycle can never complete + [Test] + public async Task Marathon_Should_Complete_A_Cycle_With_Specials() + { + List mediaItems = + [ + .. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10), + .. Show(showId: 1, seasonNumber: 0, episodes: 1, firstId: 15), + .. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20) + ]; + + PlaylistEnumerator enumerator = await CreateMarathon(mediaItems); + + List indexes = Advance(enumerator, 60); + + indexes.ShouldContain(0, "the index never returned to 0, so the cycle never completed"); + } + + // the flood and duration schedulers hand back a snapshot when an item doesn't fit, so ResetState + // has to actually rewind + [Test] + public async Task Marathon_Should_Rewind_On_ResetState() + { + List mediaItems = + [ + .. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10), + .. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20) + ]; + + PlaylistEnumerator enumerator = await CreateMarathon(mediaItems); + + + enumerator.MoveNext(Option.None); + + CollectionEnumeratorState snapshot = enumerator.State.Clone(); + int expected = enumerator.Current.Map(mi => mi.Id).IfNone(-1); + + enumerator.MoveNext(Option.None); + enumerator.MoveNext(Option.None); + + enumerator.ResetState(snapshot); + + enumerator.Current.Map(mi => mi.Id).IfNone(-1).ShouldBe(expected); + } + + [Test] + public async Task Marathon_Should_Resume_Where_It_Left_Off() + { + List mediaItems = + [ + .. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10), + .. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20) + ]; + + PlaylistEnumerator reference = await CreateMarathon(mediaItems); + + var sequence = new List(); + var states = new List(); + for (var i = 0; i < 12; i++) + { + states.Add(reference.State.Clone()); + sequence.Add(reference.Current.Map(mi => mi.Id).IfNone(-1)); + reference.MoveNext(Option.None); + } + + var mismatches = new List(); + for (var i = 0; i < states.Count; i++) + { + PlaylistEnumerator resumed = await CreateMarathon(mediaItems, states[i]); + int actual = resumed.Current.Map(mi => mi.Id).IfNone(-1); + if (actual != sequence[i]) + { + mismatches.Add($"position {i} (index {states[i].Index}): expected {sequence[i]}, got {actual}"); + } + } + + mismatches.ShouldBeEmpty(string.Join("; ", mismatches)); + } + + private async Task CreateMarathon( + List mediaItems, + CollectionEnumeratorState state = null) + { + IMediaCollectionRepository repo = Substitute.For(); + var helper = new MarathonHelper(repo); + + Option maybeEnumerator = await helper.GetEnumerator( + mediaItems, + MarathonGroupBy.Show, + marathonShuffleGroups: false, + marathonShuffleItems: false, + marathonBatchSize: Option.None, + state ?? new CollectionEnumeratorState { Seed = Seed, Index = 0 }, + _cancellationToken); + + return maybeEnumerator.IfNone(() => throw new InvalidOperationException("no marathon enumerator")); + } + + private static List Advance(PlaylistEnumerator enumerator, int count) + { + var indexes = new List(); + for (var i = 0; i < count; i++) + { + enumerator.MoveNext(Option.None); + indexes.Add(enumerator.State.Index); + } + + return indexes; + } + + private static List Show(int showId, int seasonNumber, int episodes, int firstId) + { + var seasonId = showId * 100 + seasonNumber; + var season = new Season { Id = seasonId, ShowId = showId, SeasonNumber = seasonNumber }; + + return Enumerable.Range(0, episodes) + .Map(i => (MediaItem)new Episode + { + Id = firstId + i, + Season = season, + SeasonId = seasonId, + EpisodeMetadata = [new EpisodeMetadata { EpisodeNumber = i + 1 }], + MediaVersions = + [ + new MediaVersion + { + Duration = TimeSpan.FromMinutes(30), + MediaFiles = [new MediaFile { Path = $"/fake/path/{firstId + i}" }], + Chapters = [] + } + ] + }) + .ToList(); + } +} diff --git a/ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs index c12593b1b..369733a37 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs @@ -457,6 +457,73 @@ public class PlaylistEnumeratorTests continued.ShouldBe(reference.Skip(6).Take(6)); } + // a rebuild only has the persisted seed and index, so a shuffle that depends on anything else + // diverges from the playout the previous build was part way through + [Test] + public async Task Shuffled_Playlist_Should_Resume_The_Same_Way_After_A_Cycle() + { + IMediaCollectionRepository repo = Substitute.For(); + + // single-item entries, so each move names its own playlist item + Dictionary> BuildMap() => + Range(1, 4).ToDictionary( + i => new PlaylistItem + { + Id = i, + Index = i - 1, + PlaybackOrder = PlaybackOrder.Chronological, + PlayAll = false, + CollectionType = CollectionType.Collection, + CollectionId = i + }, + i => new List { FakeMovie(i) }); + + const int SEED = 12345; + + PlaylistEnumerator live = await PlaylistEnumerator.Create( + repo, + BuildMap(), + new CollectionEnumeratorState { Seed = SEED, Index = 0 }, + shufflePlaylistItems: true, + batchSize: Option.None, + randomStartPoint: false, + CancellationToken.None); + + // past the end of the first cycle, which reseeds and reshuffles + for (var i = 0; i < 6; i++) + { + live.MoveNext(Option.None); + } + + CollectionEnumeratorState persisted = live.State.Clone(); + persisted.Seed.ShouldNotBe(SEED, "the cycle should have completed and reseeded"); + + var expected = new List(); + for (var i = 0; i < 4; i++) + { + expected.AddRange(live.Current.Map(mi => mi.Id)); + live.MoveNext(Option.None); + } + + PlaylistEnumerator rebuilt = await PlaylistEnumerator.Create( + repo, + BuildMap(), + persisted.Clone(), + shufflePlaylistItems: true, + batchSize: Option.None, + randomStartPoint: false, + CancellationToken.None); + + var continued = new List(); + for (var i = 0; i < 4; i++) + { + continued.AddRange(rebuilt.Current.Map(mi => mi.Id)); + rebuilt.MoveNext(Option.None); + } + + continued.ShouldBe(expected); + } + private static Movie FakeMovie(int id) => new() { Id = id, diff --git a/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs index d83b5b243..444bf565f 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs @@ -134,11 +134,8 @@ public class ShuffledContentTests shuffledContent.State.Seed.ShouldNotBe(MagicSeed); } - /// - /// MoveNext walks (and wraps at) the flattened item count, so an index up to one less than - /// that is valid state. The constructor validates against the group count instead, so any - /// index at or above it is thrown away even though the enumerator itself produced it. - /// + // MoveNext wraps at the flattened item count, so any index below that is valid state the + // enumerator produced itself [Test] public void State_Should_Not_Reset_When_Valid_For_Grouped_Items() { diff --git a/ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs b/ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs index 21212cbb0..e2b8f22d9 100644 --- a/ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs +++ b/ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs @@ -14,6 +14,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator private CloneableRandom _random; private bool _shufflePlaylistItems; private List _sortedEnumerators; + + // a cycle start is the only position that can be restored directly; see RewindToCycleStart + private List _enumeratorsInPlaylistOrder; + private Dictionary _childStatesAtCycleStart; + private int _itemsTakenFromCurrent; private Option _batchSize = Option.None; @@ -31,10 +36,16 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator public int EnumeratorIndex { get; private set; } + // index and seed don't describe a playlist position, so the only way back to one is to replay to it public void ResetState(CollectionEnumeratorState state) { - // seed doesn't matter here - State.Index = state.Index; + // nothing has moved since this state was handed out, so there is nothing to rewind + if (State.Index != state.Index || State.Seed != state.Seed) + { + RewindToCycleStart(state.Seed); + ReplayTo(state.Index); + } + State.Started = state.Started; } @@ -147,14 +158,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator foreach (PlaylistItem playlistItem in playlistItemMap.Keys.OrderBy(i => i.Index)) { List items = playlistItemMap[playlistItem]; - foreach (MediaItem mediaItem in items) - { - result._allMediaItemIds.Add(mediaItem.Id); - if (playlistItem.IncludeInProgramGuide) - { - result._idsToIncludeInEPG.Add(mediaItem.Id); - } + // an item that can never play would leave the cycle permanently incomplete + if (playlistItem.PlaybackOrder is PlaybackOrder.SeasonEpisode) + { + items = SeasonEpisodeMediaCollectionEnumerator.Playable(items); } var collectionKey = CollectionKey.ForPlaylistItem(playlistItem); @@ -162,6 +170,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator { result._sortedEnumerators.Add( new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count)); + result.TrackMediaItemIds(items, playlistItem.IncludeInProgramGuide); continue; } @@ -211,7 +220,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator } enumerator = new SeasonEpisodeMediaCollectionEnumerator(items, initState); - // season, episode will filter out season 0, so we may get an empty enumerator back + // season 0 is already gone, so this item had nothing else in it if (enumerator.Count == 0) { enumerator = null; @@ -229,6 +238,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator enumeratorMap.Add(collectionKey, enumerator); result._sortedEnumerators.Add( new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count)); + result.TrackMediaItemIds(items, playlistItem.IncludeInProgramGuide); } } @@ -240,6 +250,13 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator .OrderBy(identity) .HeadOrNone(); + // captured before anything moves, so RewindToCycleStart can put the children back + result._enumeratorsInPlaylistOrder = [.. result._sortedEnumerators]; + result._childStatesAtCycleStart = result._sortedEnumerators + .Map(e => e.Enumerator) + .Distinct() + .ToDictionary(e => e, e => e.State.Clone()); + result._random = new CloneableRandom(state.Seed); if (shufflePlaylistItems) @@ -256,16 +273,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator state.Index = 0; } - while (result.State.Index < state.Index) - { - result.MoveNext(Option.None); - - // previous state is no longer valid; playlist now has fewer items - if (result.State.Index == 0) - { - break; - } - } + result.ReplayTo(state.Index); var childEnumerators = new List(); foreach ((IMediaCollectionEnumerator enumerator, _, _) in result._sortedEnumerators) @@ -281,15 +289,76 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator return result; } + private void TrackMediaItemIds(List items, bool includeInProgramGuide) + { + foreach (MediaItem mediaItem in items) + { + _allMediaItemIds.Add(mediaItem.Id); + + if (includeInProgramGuide) + { + _idsToIncludeInEPG.Add(mediaItem.Id); + } + } + } + + // must leave exactly what Create leaves for this seed, or replaying from here won't match a rebuild + private void RewindToCycleStart(int seed) + { + foreach ((IMediaCollectionEnumerator enumerator, CollectionEnumeratorState childState) in + _childStatesAtCycleStart) + { + enumerator.ResetState(childState.Clone()); + } + + _sortedEnumerators = [.. _enumeratorsInPlaylistOrder]; + _random = new CloneableRandom(seed); + + if (_shufflePlaylistItems) + { + _sortedEnumerators = ShufflePlaylistItems(); + } + + _remainingMediaItemIds.Clear(); + _remainingMediaItemIds.UnionWith(_allMediaItemIds); + + EnumeratorIndex = 0; + _itemsTakenFromCurrent = 0; + + State.Seed = seed; + State.Index = 0; + } + + private void ReplayTo(int index) + { + if (_sortedEnumerators.Count == 0) + { + return; + } + + while (State.Index < index) + { + MoveNext(Option.None); + + // previous state is no longer valid; playlist now has fewer items + if (State.Index == 0) + { + break; + } + } + } + + // shuffles playlist order rather than current order, so the result depends only on that order and the + // seed - all a rebuild has to work from private List ShufflePlaylistItems() { - if (_sortedEnumerators.Count < 3) + if (_enumeratorsInPlaylistOrder.Count < 3) { - return _sortedEnumerators; + return [.. _enumeratorsInPlaylistOrder]; } - EnumeratorPlayAllCount[] copy = _sortedEnumerators.ToArray(); - EnumeratorPlayAllCount last = _sortedEnumerators.Last(); + EnumeratorPlayAllCount[] copy = _enumeratorsInPlaylistOrder.ToArray(); + EnumeratorPlayAllCount last = _enumeratorsInPlaylistOrder.Last(); do { diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 10c5c202b..d95f60a09 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -169,9 +169,8 @@ public class PlayoutBuilder : IPlayoutBuilder // _logger.LogDebug("All anchors: {@Anchors}", playout.ProgramScheduleAnchors); - // 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 + // these have to go rather than just be ignored; 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); @@ -457,8 +456,8 @@ public class PlayoutBuilder : IPlayoutBuilder return result; } - // 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 + // one whole day at a time; this is also how alternate schedules switch, since the active + // schedule is selected once per pass while (finish < playoutFinish) { if (cancellationToken.IsCancellationRequested) @@ -497,7 +496,6 @@ public class PlayoutBuilder : IPlayoutBuilder if (start < playoutFinish) { - // build the remaining partial day _logger.LogDebug("Building final playout from {Start} to {Finish}", start, playoutFinish); Either buildResult = await BuildPlayoutItemsForPass( playout, @@ -901,10 +899,8 @@ 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 + // has to happen here rather than at the end of a pass; pass boundaries only aspirationally + // line up with midnights, and the final partial pass never ends on one if (nextState.CurrentTime.ToLocalTime().Date > playoutBuilderState.CurrentTime.ToLocalTime().Date) { SaveCheckpointAnchors(playout, collectionEnumerators, nextState.CurrentTime); @@ -1212,11 +1208,7 @@ public class PlayoutBuilder : IPlayoutBuilder && 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. - /// + // refresh rewinds to the checkpoint dated today, so every local day of the playout needs one private static void SaveCheckpointAnchors( Playout playout, Dictionary collectionEnumerators, @@ -1227,15 +1219,13 @@ public class PlayoutBuilder : IPlayoutBuilder 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 + // rebuilding a day replaces its checkpoint; the new state is the one matching 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 + // the enumerator keeps mutating its own state as the build continues CollectionEnumeratorState state = enumerator.State.Clone(); foreach (PlayoutProgramScheduleAnchor existing in maybeExisting) diff --git a/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs index 10845e45a..eabfe8164 100644 --- a/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs +++ b/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs @@ -15,8 +15,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu { CurrentIncludeInProgramGuide = Option.None; - _sortedMediaItems = mediaItems - .Filter(mi => (mi is not Episode episode) || (episode.Season?.SeasonNumber ?? 0) > 0) + _sortedMediaItems = Playable(mediaItems) .OrderBy(identity, new SeasonEpisodeMediaComparer()).ToList(); _lazyMinimumDuration = new Lazy>(() => _sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone()); @@ -35,6 +34,10 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu } } + // shared with callers that need to know what this will play, not what they handed it + public static List Playable(IEnumerable mediaItems) => + mediaItems.Filter(mi => (mi is not Episode episode) || (episode.Season?.SeasonNumber ?? 0) > 0).ToList(); + public void ResetState(CollectionEnumeratorState state) { // seed doesn't matter here