From 30288fab8b22c22a37599b19891616b3479aea42 Mon Sep 17 00:00:00 2001
From: Jason Dove <1695733+jasongdove@users.noreply.github.com>
Date: Sun, 26 Jul 2026 08:26:00 -0500
Subject: [PATCH] fix: don't prematurely reset shuffled collection progress
---
CHANGELOG.md | 3 +
.../AlternateScheduleAnchorTests.cs | 227 ++++++++++++++++++
.../Scheduling/ShuffledContentTests.cs | 22 ++
.../ShuffledMediaCollectionEnumerator.cs | 2 +-
4 files changed, 253 insertions(+), 1 deletion(-)
create mode 100644 ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/AlternateScheduleAnchorTests.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b2e7da62..4d7c0f345 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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 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 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
new file mode 100644
index 000000000..df6513926
--- /dev/null
+++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/AlternateScheduleAnchorTests.cs
@@ -0,0 +1,227 @@
+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;
+
+///
+/// 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.
+///
+[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.
+ ///
+ [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")]
+ public async Task Inactive_Alternate_Collection_Should_Keep_Its_Progress(int index, bool multiPart)
+ {
+ (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) =
+ AlternateTestData(multiPart);
+
+ playout.ProgramScheduleAnchors.Add(
+ new PlayoutProgramScheduleAnchor
+ {
+ Playout = playout,
+ PlayoutId = playout.Id,
+ CollectionType = CollectionType.Collection,
+ CollectionId = AlternateCollectionId,
+ 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
+ DateTimeOffset now = LocalTime(new DateTime(2025, 6, 11), 20);
+ now.DayOfWeek.ShouldBe(DayOfWeek.Wednesday);
+
+ Either result = await builder.Build(
+ now,
+ playout,
+ referenceData,
+ PlayoutBuildMode.Continue,
+ CancellationToken);
+
+ result.IsRight.ShouldBeTrue();
+
+ return HeadIndex(playout, AlternateCollectionId);
+ }
+
+ private static int HeadIndex(Playout playout, int collectionId) =>
+ playout.ProgramScheduleAnchors
+ .Filter(a => a.AnchorDate is null && a.CollectionId == collectionId)
+ .Map(a => a.EnumeratorState.Index)
+ .HeadOrNone()
+ .IfNone(-1);
+
+ private (PlayoutBuilder, Playout, PlayoutReferenceData) AlternateTestData(bool multiPart)
+ {
+ var primaryCollection = new Collection
+ {
+ Id = PrimaryCollectionId,
+ Name = "Primary Collection",
+ MediaItems = Enumerable.Range(1, 10)
+ .Map(i => (MediaItem)TestMovie(i, TimeSpan.FromHours(1), new DateTime(2000 + i, 1, 1)))
+ .ToList()
+ };
+
+ var season = new Season { Id = 1, ShowId = 1, SeasonNumber = 1 };
+ var alternateCollection = new Collection
+ {
+ Id = AlternateCollectionId,
+ Name = "Alternate Collection",
+ MediaItems = Enumerable.Range(1, 10)
+ .Map(i => (MediaItem)new Episode
+ {
+ Id = 100 + i,
+ Season = season,
+ SeasonId = season.Id,
+ EpisodeMetadata =
+ [
+ new EpisodeMetadata
+ {
+ EpisodeNumber = i,
+ // episodes 4 and 5 are a two-parter, so they group into one
+ Title = (multiPart, i) switch
+ {
+ (true, 4) => "Episode Four (1)",
+ (true, 5) => "Episode Five (2)",
+ _ => $"Episode {i}"
+ }
+ }
+ ],
+ MediaVersions =
+ [
+ new MediaVersion
+ {
+ Duration = TimeSpan.FromHours(1),
+ MediaFiles = [new MediaFile { Path = $"/fake/path/episode/{i}" }],
+ Chapters = []
+ }
+ ]
+ })
+ .ToList()
+ };
+
+ var collectionRepo = new FakeMediaCollectionRepository(
+ Map(
+ (primaryCollection.Id, primaryCollection.MediaItems.ToList()),
+ (alternateCollection.Id, alternateCollection.MediaItems.ToList())));
+
+ IConfigElementRepository configRepo = Substitute.For();
+ configRepo
+ .GetValue(
+ Arg.Is(k => k.Key == ConfigElementKey.PlayoutDaysToBuild.Key),
+ Arg.Any())
+ .Returns(Some(1));
+
+ var builder = new PlayoutBuilder(
+ configRepo,
+ collectionRepo,
+ new FakeTelevisionRepository(),
+ Substitute.For(),
+ Substitute.For(),
+ new MockFileSystem(),
+ Substitute.For(),
+ Logger);
+
+ var primarySchedule = new ProgramSchedule
+ {
+ Id = 1,
+ KeepMultiPartEpisodesTogether = true,
+ Items =
+ [
+ new ProgramScheduleItemFlood
+ {
+ Id = 1,
+ Index = 1,
+ CollectionType = CollectionType.Collection,
+ Collection = primaryCollection,
+ CollectionId = primaryCollection.Id,
+ StartTime = null,
+ PlaybackOrder = PlaybackOrder.Chronological
+ }
+ ]
+ };
+
+ var alternateSchedule = new ProgramSchedule
+ {
+ Id = 2,
+ KeepMultiPartEpisodesTogether = true,
+ Items =
+ [
+ new ProgramScheduleItemFlood
+ {
+ Id = 2,
+ Index = 1,
+ CollectionType = CollectionType.Collection,
+ Collection = alternateCollection,
+ CollectionId = alternateCollection.Id,
+ StartTime = null,
+ PlaybackOrder = PlaybackOrder.SeasonEpisode
+ }
+ ]
+ };
+
+ var playout = new Playout
+ {
+ Id = 1,
+ ScheduleKind = PlayoutScheduleKind.Classic,
+ ProgramSchedule = primarySchedule,
+ Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
+ Items = [],
+ ProgramScheduleAnchors = [],
+ ProgramScheduleAlternates = [],
+ FillGroupIndices = []
+ };
+
+ var alternates = new List
+ {
+ new()
+ {
+ Id = 1,
+ Index = 1,
+ PlayoutId = playout.Id,
+ ProgramSchedule = alternateSchedule,
+ ProgramScheduleId = alternateSchedule.Id,
+ DaysOfWeek = [DayOfWeek.Sunday],
+ DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
+ MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
+ }
+ };
+
+ var referenceData = new PlayoutReferenceData(
+ playout.Channel,
+ Option.None,
+ [],
+ [],
+ primarySchedule,
+ alternates,
+ [],
+ TimeSpan.Zero);
+
+ return (builder, playout, referenceData);
+ }
+
+ private static DateTimeOffset LocalTime(DateTime date, int hour) =>
+ new DateTimeOffset(
+ DateTime.SpecifyKind(date, DateTimeKind.Unspecified),
+ TimeZoneInfo.Local.GetUtcOffset(date)) + TimeSpan.FromHours(hour);
+}
diff --git a/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs
index 98490b17f..d83b5b243 100644
--- a/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs
+++ b/ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs
@@ -134,6 +134,28 @@ 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.
+ ///
+ [Test]
+ public void State_Should_Not_Reset_When_Valid_For_Grouped_Items()
+ {
+ List contents = Episodes(10);
+
+ // 9 groups covering 10 items, so MoveNext wraps at 10 while the group count is 9
+ var groupedMediaItems = contents.Take(8).Map(mi => new GroupedMediaItem(mi, null)).ToList();
+ groupedMediaItems.Add(new GroupedMediaItem(contents[8], [contents[9]]));
+
+ var state = new CollectionEnumeratorState { Index = 9, Seed = MagicSeed };
+
+ var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state, _cancellationToken);
+
+ shuffledContent.State.Index.ShouldBe(9);
+ shuffledContent.State.Seed.ShouldBe(MagicSeed);
+ }
+
private static List Episodes(int count) =>
Range(1, count).Map(i => (MediaItem)new Episode
{
diff --git a/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
index 86a03e4dc..f03d55283 100644
--- a/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
@@ -24,7 +24,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
_mediaItems = mediaItems;
_cancellationToken = cancellationToken;
- if (state.Index >= _mediaItems.Count)
+ if (state.Index >= _mediaItemCount)
{
state.Index = 0;
state.Seed = new Random(state.Seed).Next();