diff --git a/CHANGELOG.md b/CHANGELOG.md index f63b5c85b..5869c5393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,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 +- Fix playlists and marathons not continuing from the saved position after a restart or a playout rebuild + - Affects playlists and marathons in sequential and scripted schedules, and playlists used as block deco filler + - Episodes that had already played would repeat, and others would be skipped each time the collection came up + - Only the last collection of a playlist kept its position (regression from `v26.7.0`) + - Sequential and scripted schedules first build after upgrading may still start at the wrong item; every build after that will be correct - 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 diff --git a/ErsatzTV.Core.Tests/Scheduling/PlaylistHistoryTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlaylistHistoryTests.cs new file mode 100644 index 000000000..fc95b9c37 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/PlaylistHistoryTests.cs @@ -0,0 +1,497 @@ +using System.Collections.Immutable; +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.BlockScheduling; +using ErsatzTV.Core.Scheduling.Engine; +using ErsatzTV.Core.Scheduling.YamlScheduling; +using ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; +using ErsatzTV.Core.Scheduling.YamlScheduling.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Core.Tests.Scheduling; + +// the scripted, block and yaml schedulers each restore a playlist from history with the same 3 lines. +// a playlist position is the index of the primary history row; a child index counts the items of one +// collection, and PlaylistEnumerator.ResetState with a child index puts every child back to the cycle +// start +[TestFixture] +public class PlaylistHistoryTests +{ + private const int PlayoutSeed = 12345; + + private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan ItemDuration = TimeSpan.FromMinutes(30); + + [SetUp] + public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token; + + private CancellationToken _cancellationToken; + + // a user reported this case: a sequential schedule with marathon pools repeated some episodes and + // skipped others after a rebuild + [Test] + public async Task Marathon_Should_Resume_Where_The_Previous_Build_Stopped() + { + const int BUILD_ONE_COUNT = 20; + const int COMPARE_COUNT = 12; + + IMediaCollectionRepository repo = FakeShowRepository(shows: 6, episodesPerShow: 8); + YamlPlayoutContentMarathonItem marathon = MarathonContent(shows: 6); + var definition = new YamlPlayoutDefinition { Content = [marathon] }; + var playout = new Playout { Id = 1, Seed = PlayoutSeed, PlayoutHistory = [] }; + + var buildOneContext = new YamlPlayoutContext(playout, definition, 1) { CurrentTime = Start }; + var buildOneCache = new EnumeratorCache(repo, NullLogger.Instance); + PlaylistEnumerator buildOne = await GetPlaylistEnumerator(buildOneCache, buildOneContext, marathon.Key); + + var playedInBuildOne = new List(); + var history = new List(); + DateTimeOffset currentTime = Start; + for (var i = 0; i < BUILD_ONE_COUNT; i++) + { + playedInBuildOne.Add(CurrentId(buildOne)); + history.AddRange(RecordHistory(buildOneContext, marathon.Key, buildOne, currentTime)); + buildOne.MoveNext(currentTime); + currentTime += ItemDuration; + } + + // the live enumerator never lost its position, so what it plays next is the correct order + List expected = Take(buildOne, COMPARE_COUNT); + + // build 2 starts with a new enumerator that has only the saved history + var buildTwoContext = new YamlPlayoutContext(playout, definition, 1) { CurrentTime = currentTime }; + var buildTwoCache = new EnumeratorCache(repo, NullLogger.Instance); + var applyHistory = new YamlPlayoutApplyHistoryHandler(buildTwoCache); + + bool applied = await applyHistory.Handle( + history, + buildTwoContext, + marathon, + NullLogger.Instance, + _cancellationToken); + + applied.ShouldBeTrue(); + + PlaylistEnumerator buildTwo = await GetPlaylistEnumerator(buildTwoCache, buildTwoContext, marathon.Key); + List actual = Take(buildTwo, COMPARE_COUNT); + + actual.ShouldBe( + expected, + $"build 1 played [{string.Join(", ", playedInBuildOne)}]"); + } + + // the block scheduler restores playlist filler with its own copy of the same 3 lines + [Test] + public async Task Block_Playlist_Filler_Should_Resume_Where_The_Previous_Build_Stopped() + { + const int PLAYLIST_ID = 7; + const int BUILD_ONE_COUNT = 11; + const int COMPARE_COUNT = 8; + const string HISTORY_KEY = "block-playlist-filler"; + + IMediaCollectionRepository repo = FakePlaylistRepository(PLAYLIST_ID, collections: 3, itemsPerCollection: 4); + + PlaylistEnumerator buildOne = (PlaylistEnumerator)await BlockPlayoutEnumerator.PlaylistForFiller( + repo, + PLAYLIST_ID, + Start, + PlayoutSeed, + [], + seedOffset: 0, + HISTORY_KEY, + _cancellationToken); + + var history = new List(); + DateTimeOffset currentTime = Start; + for (var i = 0; i < BUILD_ONE_COUNT; i++) + { + history.Add(BlockFillerHistoryFor(buildOne, HISTORY_KEY, currentTime)); + buildOne.MoveNext(currentTime); + currentTime += ItemDuration; + } + + List expected = Take(buildOne, COMPARE_COUNT); + + var buildTwo = (PlaylistEnumerator)await BlockPlayoutEnumerator.PlaylistForFiller( + repo, + PLAYLIST_ID, + currentTime, + PlayoutSeed, + history, + seedOffset: 0, + HISTORY_KEY, + _cancellationToken); + + Take(buildTwo, COMPARE_COUNT).ShouldBe(expected); + } + + // one ResetState call for each child would put every child back to the cycle start, so the second + // child would discard the position of the first + [Test] + public async Task Applying_History_Should_Restore_Every_Childs_Position() + { + const int BUILD_ONE_COUNT = 11; + const string HISTORY_KEY = "playlist"; + + IMediaCollectionRepository repo = FakePlaylistRepository(playlistId: 1, collections: 3, itemsPerCollection: 4); + Dictionary> itemMap = await repo.GetPlaylistItemMap(1, _cancellationToken); + + SchedulingEngine engine = CreateEngine(repo); + PlaylistEnumerator buildOne = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); + + DateTimeOffset currentTime = Start; + var history = new List(); + for (var i = 0; i < BUILD_ONE_COUNT; i++) + { + history.AddRange(ScriptedHistoryFor(engine, buildOne, HISTORY_KEY, currentTime)); + buildOne.MoveNext(currentTime); + currentTime += ItemDuration; + } + + List expected = ChildItemIds(buildOne); + + // the children need different positions, or a rewind to the cycle start would not show + expected.Distinct().Count().ShouldBeGreaterThan(1); + + PlaylistEnumerator buildTwo = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); + ApplyScriptedHistory(engine, history, currentTime, HISTORY_KEY, itemMap, buildTwo); + + ChildItemIds(buildTwo).ShouldBe(expected, "at least one child was rewound to the cycle start"); + } + + // the index of the primary history row is the cycle position of the playlist, not the slot number + // of the current child + [Test] + public async Task Playlist_Should_Restore_Its_Cycle_Position_From_History() + { + const int BUILD_ONE_COUNT = 7; + + IMediaCollectionRepository repo = FakePlaylistRepository(playlistId: 1, collections: 3, itemsPerCollection: 4); + Dictionary> itemMap = await repo.GetPlaylistItemMap(1, _cancellationToken); + + PlaylistEnumerator buildOne = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); + + DateTimeOffset currentTime = Start; + var history = new List(); + for (var i = 0; i < BUILD_ONE_COUNT; i++) + { + history.Add(BlockFillerHistoryFor(buildOne, "playlist", currentTime)); + buildOne.MoveNext(currentTime); + currentTime += ItemDuration; + } + + int expectedPlaylistIndex = buildOne.State.Index; + + var buildTwo = (PlaylistEnumerator)await BlockPlayoutEnumerator.PlaylistForFiller( + repo, + 1, + currentTime, + PlayoutSeed, + history, + seedOffset: 0, + "playlist", + _cancellationToken); + + buildTwo.State.Index.ShouldBe(expectedPlaylistIndex); + } + + // the scripted scheduler reaches the same 3 lines through SchedulingEngine.ApplyPlaylistHistory + [Test] + public async Task Scripted_Playlist_Should_Resume_Where_The_Previous_Build_Stopped() + { + const int BUILD_ONE_COUNT = 11; + const int COMPARE_COUNT = 8; + const string HISTORY_KEY = "scripted-playlist"; + + IMediaCollectionRepository repo = FakePlaylistRepository(playlistId: 1, collections: 3, itemsPerCollection: 4); + Dictionary> itemMap = await repo.GetPlaylistItemMap(1, _cancellationToken); + + SchedulingEngine engine = CreateEngine(repo); + PlaylistEnumerator buildOne = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); + + DateTimeOffset currentTime = Start; + var history = new List(); + for (var i = 0; i < BUILD_ONE_COUNT; i++) + { + history.AddRange(ScriptedHistoryFor(engine, buildOne, HISTORY_KEY, currentTime)); + buildOne.MoveNext(currentTime); + currentTime += ItemDuration; + } + + List expected = Take(buildOne, COMPARE_COUNT); + + PlaylistEnumerator buildTwo = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); + ApplyScriptedHistory(engine, history, currentTime, HISTORY_KEY, itemMap, buildTwo); + + Take(buildTwo, COMPARE_COUNT).ShouldBe(expected); + } + + private static async Task CreatePlaylistEnumerator( + IMediaCollectionRepository repo, + Dictionary> itemMap, + CancellationToken cancellationToken) => + await PlaylistEnumerator.Create( + repo, + itemMap, + new CollectionEnumeratorState { Seed = PlayoutSeed, Index = 0 }, + shufflePlaylistItems: false, + batchSize: Option.None, + randomStartPoint: false, + cancellationToken); + + private static async Task GetPlaylistEnumerator( + EnumeratorCache cache, + YamlPlayoutContext context, + string contentKey) + { + Option maybeEnumerator = + await cache.GetCachedEnumeratorForContent(context, contentKey, CancellationToken.None); + + return maybeEnumerator + .Map(e => e as PlaylistEnumerator) + .IfNone(() => throw new InvalidOperationException("no playlist enumerator")); + } + + private static List RecordHistory( + YamlPlayoutContext context, + string contentKey, + PlaylistEnumerator enumerator, + DateTimeOffset startTime) + { + var playoutItem = new PlayoutItem + { + Start = startTime.UtcDateTime, + Finish = (startTime + ItemDuration).UtcDateTime + }; + + MediaItem mediaItem = enumerator.Current.IfNone(() => throw new InvalidOperationException("no current item")); + + return HistoryRecorder.Record( + context, + contentKey, + enumerator, + playoutItem, + mediaItem, + NullLogger.Instance); + } + + private static SchedulingEngine CreateEngine(IMediaCollectionRepository repo) => + new( + repo, + Substitute.For(), + Substitute.For(), + NullLogger.Instance); + + private static List ScriptedHistoryFor( + SchedulingEngine engine, + PlaylistEnumerator enumerator, + string historyKey, + DateTimeOffset startTime) + { + var playoutItem = new PlayoutItem + { + Start = startTime.UtcDateTime, + Finish = (startTime + ItemDuration).UtcDateTime + }; + + MediaItem mediaItem = enumerator.Current.IfNone(() => throw new InvalidOperationException("no current item")); + + return engine.GetHistoryForItem( + new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None), + playoutItem, + mediaItem); + } + + private static void ApplyScriptedHistory( + SchedulingEngine engine, + List history, + DateTimeOffset currentTime, + string historyKey, + Dictionary> itemMap, + PlaylistEnumerator enumerator) + { + // ApplyPlaylistHistory reads the history and the current time from engine state + engine.WithReferenceData( + new PlayoutReferenceData( + new Channel(Guid.NewGuid()) { Id = 1, Number = "1", Name = "Playlist history test" }, + Option.None, + [], + [], + null, + [], + history, + TimeSpan.Zero)); + engine.BuildBetween(currentTime, currentTime.AddDays(1)); + + engine.ApplyPlaylistHistory( + historyKey, + itemMap.ToImmutableDictionary(x => CollectionKey.ForPlaylistItem(x.Key), x => x.Value), + enumerator); + } + + // BlockPlayoutFillerBuilder writes one row for each item and no child rows, so the index of the + // playlist is the only position it saves + private static PlayoutHistory BlockFillerHistoryFor( + PlaylistEnumerator enumerator, + string historyKey, + DateTimeOffset startTime) => + new() + { + PlaybackOrder = PlaybackOrder.Shuffle, + Index = enumerator.State.Index, + When = startTime.UtcDateTime, + Finish = (startTime + ItemDuration).UtcDateTime, + Key = historyKey, + Details = HistoryDetails.ForMediaItem( + enumerator.Current.IfNone(() => throw new InvalidOperationException("no current item"))) + }; + + private static List Take(PlaylistEnumerator enumerator, int count) + { + var result = new List(); + for (var i = 0; i < count; i++) + { + result.Add(CurrentId(enumerator)); + enumerator.MoveNext(Option.None); + } + + return result; + } + + private static int CurrentId(PlaylistEnumerator enumerator) => enumerator.Current.Map(mi => mi.Id).IfNone(-1); + + private static List ChildItemIds(PlaylistEnumerator enumerator) => enumerator.ChildEnumerators + .Map(c => c.Enumerator.Current.Map(mi => mi.Id).IfNone(-1)) + .ToList(); + + private static YamlPlayoutContentMarathonItem MarathonContent(int shows) => + new() + { + Key = "marathon", + Marathon = "marathon", + Guids = Enumerable.Range(1, shows) + .Map(showId => new YamlPlayoutContentGuid { Source = "imdb", Value = $"show{showId}" }) + .ToList(), + GroupBy = "show", + ShuffleGroups = true, + ItemOrder = "chronological", + PlayAllItems = false + }; + + private static IMediaCollectionRepository FakeShowRepository(int shows, int episodesPerShow) + { + Dictionary> byGuid = Enumerable.Range(1, shows) + .ToDictionary( + showId => $"imdb://show{showId}", + showId => Episodes(showId, episodesPerShow)); + + IMediaCollectionRepository repo = Substitute.For(); + repo.GetShowItemsByShowGuids(Arg.Any>()) + .Returns(call => Task.FromResult(((List)call[0]).SelectMany(g => byGuid[g]).ToList())); + + return repo; + } + + private static IMediaCollectionRepository FakePlaylistRepository( + int playlistId, + int collections, + int itemsPerCollection) + { + Dictionary> itemMap = Enumerable.Range(1, collections) + .ToDictionary( + collectionId => new PlaylistItem + { + Id = collectionId, + Index = collectionId - 1, + PlaybackOrder = PlaybackOrder.Chronological, + PlayAll = false, + CollectionType = CollectionType.Collection, + CollectionId = collectionId, + IncludeInProgramGuide = true + }, + collectionId => Enumerable.Range(0, itemsPerCollection) + .Map(i => (MediaItem)FakeMovie(collectionId * 100 + i)) + .ToList()); + + IMediaCollectionRepository repo = Substitute.For(); + repo.GetPlaylistItemMap(playlistId, Arg.Any()) + .Returns(_ => Task.FromResult(itemMap)); + + return repo; + } + + private static List Episodes(int showId, int episodes) + { + int seasonId = showId * 100 + 1; + var season = new Season { Id = seasonId, ShowId = showId, SeasonNumber = 1 }; + + return Enumerable.Range(0, episodes) + .Map(i => (MediaItem)new Episode + { + Id = showId * 100 + i, + Season = season, + SeasonId = seasonId, + EpisodeMetadata = + [ + new EpisodeMetadata + { + EpisodeNumber = i + 1, + ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) + } + ], + MediaVersions = + [ + new MediaVersion + { + Duration = ItemDuration, + MediaFiles = [new MediaFile { Path = $"/fake/path/{showId}-{i}" }], + Chapters = [] + } + ] + }) + .ToList(); + } + + private static Movie FakeMovie(int id) => new() + { + Id = id, + MediaVersions = [new MediaVersion { Duration = ItemDuration, MediaFiles = [], Chapters = [] }], + MovieMetadata = + [ + new MovieMetadata + { + Title = $"Movie {id}", + ReleaseDate = new DateTime(2020, 1, 1).AddDays(id) + } + ] + }; + + // GetHistoryForItem is protected on the real content handler, so this subclass gives the test the + // production writer instead of a copy + private sealed class HistoryRecorder(EnumeratorCache enumeratorCache) : YamlPlayoutContentHandler(enumeratorCache) + { + public static List Record( + YamlPlayoutContext context, + string contentKey, + IMediaCollectionEnumerator enumerator, + PlayoutItem playoutItem, + MediaItem mediaItem, + ILogger logger) => + GetHistoryForItem(context, contentKey, enumerator, playoutItem, mediaItem, logger); + + public override Task Handle( + YamlPlayoutContext context, + YamlPlayoutInstruction instruction, + PlayoutBuildMode mode, + Func executeSequence, + ILogger logger, + CancellationToken cancellationToken) => throw new NotSupportedException(); + } +} diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs index 4c6586f42..800ec21e2 100644 --- a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs +++ b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs @@ -191,7 +191,14 @@ public static class BlockPlayoutEnumerator foreach (PlayoutHistory primaryHistory in maybePrimaryHistory) { - var hasSetEnumeratorIndex = false; + // the primary row holds the playlist index; a child index counts the items of one + // collection, so it does not describe a playlist position + enumerator.ResetState( + new CollectionEnumeratorState + { + Seed = enumerator.State.Seed, + Index = primaryHistory.Index + }); var childEnumeratorKeys = enumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList(); foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in @@ -224,13 +231,8 @@ public static class BlockPlayoutEnumerator // h.Details, // h.IsCurrentChild); - enumerator.ResetState( - new CollectionEnumeratorState - { - Seed = enumerator.State.Seed, - Index = h.Index + (h.IsCurrentChild ? 1 : 0) - }); - + // the collection may have changed since the last build, so the replayed + // position can point at the wrong item if (itemPlaybackOrder is PlaybackOrder.Chronological) { HistoryDetails.MoveToNextItem( @@ -241,21 +243,14 @@ public static class BlockPlayoutEnumerator true); } + // the playlist order may have changed since the last build if (h.IsCurrentChild) { - // try to find enumerator based on collection key enumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey)); - hasSetEnumeratorIndex = true; } } } - if (!hasSetEnumeratorIndex) - { - // falling back to enumerator based on index - enumerator.SetEnumeratorIndex(primaryHistory.Index); - } - // only move next at the end, because that may also move // the enumerator index enumerator.MoveNext(Option.None); diff --git a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs index 2139f65c9..70d88c5c3 100644 --- a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs +++ b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs @@ -1239,7 +1239,7 @@ public class SchedulingEngine( return Option.None; } - private void ApplyPlaylistHistory( + internal void ApplyPlaylistHistory( string historyKey, ImmutableDictionary> itemMap, PlaylistEnumerator playlistEnumerator) @@ -1268,7 +1268,14 @@ public class SchedulingEngine( foreach (PlayoutHistory primaryHistory in maybePrimaryHistory) { - var hasSetEnumeratorIndex = false; + // the primary row holds the playlist index; a child index counts the items of one + // collection, so it does not describe a playlist position + playlistEnumerator.ResetState( + new CollectionEnumeratorState + { + Seed = playlistEnumerator.State.Seed, + Index = primaryHistory.Index + }); var childEnumeratorKeys = playlistEnumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList(); foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in @@ -1301,13 +1308,8 @@ public class SchedulingEngine( // h.Details, // h.IsCurrentChild); - playlistEnumerator.ResetState( - new CollectionEnumeratorState - { - Seed = playlistEnumerator.State.Seed, - Index = h.Index + (h.IsCurrentChild ? 1 : 0) - }); - + // the collection may have changed since the last build, so the replayed + // position can point at the wrong item if (itemPlaybackOrder is PlaybackOrder.Chronological) { HistoryDetails.MoveToNextItem( @@ -1318,21 +1320,14 @@ public class SchedulingEngine( true); } + // the playlist order may have changed since the last build if (h.IsCurrentChild) { - // try to find enumerator based on collection key playlistEnumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey)); - hasSetEnumeratorIndex = true; } } } - if (!hasSetEnumeratorIndex) - { - // falling back to enumerator based on index - playlistEnumerator.SetEnumeratorIndex(primaryHistory.Index); - } - // only move next at the end, because that may also move // the enumerator index playlistEnumerator.MoveNext(Option.None); @@ -1391,7 +1386,7 @@ public class SchedulingEngine( } } - private List GetHistoryForItem( + internal List GetHistoryForItem( EnumeratorDetails enumeratorDetails, PlayoutItem playoutItem, MediaItem mediaItem) @@ -1405,7 +1400,7 @@ public class SchedulingEngine( { PlayoutId = _state.PlayoutId, PlaybackOrder = enumeratorDetails.PlaybackOrder, - Index = playlistEnumerator.EnumeratorIndex, + Index = playlistEnumerator.State.Index, When = playoutItem.StartOffset.UtcDateTime, Finish = playoutItem.FinishOffset.UtcDateTime, Key = enumeratorDetails.HistoryKey, diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutApplyHistoryHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutApplyHistoryHandler.cs index 93abeac72..490a79ebb 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutApplyHistoryHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutApplyHistoryHandler.cs @@ -62,7 +62,14 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache) foreach (PlayoutHistory primaryHistory in maybePrimaryHistory) { - var hasSetEnumeratorIndex = false; + // the primary row holds the playlist index; a child index counts the items of one + // collection, so it does not describe a playlist position + playlistEnumerator.ResetState( + new CollectionEnumeratorState + { + Seed = playlistEnumerator.State.Seed, + Index = primaryHistory.Index + }); var childEnumeratorKeys = playlistEnumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList(); foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in @@ -96,13 +103,8 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache) // h.Details, // h.IsCurrentChild); - enumerator.ResetState( - new CollectionEnumeratorState - { - Seed = enumerator.State.Seed, - Index = h.Index + (h.IsCurrentChild ? 1 : 0) - }); - + // the collection may have changed since the last build, so the replayed + // position can point at the wrong item if (itemPlaybackOrder is PlaybackOrder.Chronological) { HistoryDetails.MoveToNextItem( @@ -113,21 +115,14 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache) true); } + // the playlist order may have changed since the last build if (h.IsCurrentChild) { - // try to find enumerator based on collection key playlistEnumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey)); - hasSetEnumeratorIndex = true; } } } - if (!hasSetEnumeratorIndex) - { - // falling back to enumerator based on index - playlistEnumerator.SetEnumeratorIndex(primaryHistory.Index); - } - // only move next at the end, because that may also move // the enumerator index playlistEnumerator.MoveNext(Option.None); diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs index 99a3b2e5e..62e1b9246 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs @@ -83,7 +83,7 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache) { PlayoutId = context.Playout.Id, PlaybackOrder = playbackOrder, - Index = playlistEnumerator.EnumeratorIndex, + Index = playlistEnumerator.State.Index, When = playoutItem.StartOffset.UtcDateTime, Finish = playoutItem.FinishOffset.UtcDateTime, Key = historyKey,