diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fddbc80..45aafc19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Move dark/light mode toggle to **Settings** > **UI** +### Fixed +- Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time + ## [26.2.0] - 2026-02-02 ### Added - Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example: diff --git a/ErsatzTV.Core.Tests/Scheduling/ShuffleInOrderCollectionEnumeratorTests.cs b/ErsatzTV.Core.Tests/Scheduling/ShuffleInOrderCollectionEnumeratorTests.cs new file mode 100644 index 000000000..02f3b077b --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/ShuffleInOrderCollectionEnumeratorTests.cs @@ -0,0 +1,132 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using LanguageExt.UnsafeValueAccess; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Core.Tests.Scheduling; + +[TestFixture] +public class ShuffleInOrderCollectionEnumeratorTests +{ + [Test] + public void Should_Not_Repeat_Items_Until_Cycle_Complete() + { + var collections = new List + { + new( + 0, + 0, + "1", + Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast().ToList(), + true, + PlaybackOrder.ShuffleInOrder, + false), + new( + 0, + 0, + "2", + Enumerable.Range(11, 20).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast().ToList(), + true, + PlaybackOrder.ShuffleInOrder, + false) + }; + + var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 }; + var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None); + + var seenIds = new System.Collections.Generic.HashSet(); + for (int i = 0; i < 20; i++) + { + enumerator.Current.IsSome.ShouldBeTrue(); + int id = enumerator.Current.ValueUnsafe().Id; + seenIds.ShouldNotContain(id, $"at index {i}"); + seenIds.Add(id); + enumerator.MoveNext(Option.None); + } + + seenIds.Count.ShouldBe(20); + } + + [Test] + public void Should_Handle_Single_Collection() + { + var collections = new List + { + new( + 0, + 0, + "1", + Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast().ToList(), + true, + PlaybackOrder.ShuffleInOrder, + false) + }; + + var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 }; + var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None); + + var seenIds = new List(); + for (int i = 0; i < 10; i++) + { + seenIds.Add(enumerator.Current.ValueUnsafe().Id); + enumerator.MoveNext(Option.None); + } + + seenIds.Count.ShouldBe(10); + seenIds.ShouldBeInOrder(SortDirection.Ascending); + } + + [Test] + public void Should_Reshuffle_After_Cycle() + { + var collections = new List + { + new( + 0, + 0, + "1", + Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast().ToList(), + true, + PlaybackOrder.ShuffleInOrder, + false) + }; + + var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 }; + var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None); + + for (int i = 0; i < 10; i++) + { + enumerator.MoveNext(Option.None); + } + + enumerator.State.Index.ShouldBe(0); + // Should have a new seed + enumerator.State.Seed.ShouldNotBe(1234); + } + + [Test] + public void ResetState_Should_Update_Seed() + { + var collections = new List + { + new( + 0, + 0, + "1", + Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast().ToList(), + true, + PlaybackOrder.ShuffleInOrder, + false) + }; + + var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 }; + var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None); + + var newState = new CollectionEnumeratorState { Seed = 5678, Index = 5 }; + enumerator.ResetState(newState); + + enumerator.State.Seed.ShouldBe(5678); + enumerator.State.Index.ShouldBe(5); + } +} diff --git a/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs index 8effc19f8..308334bfc 100644 --- a/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs +++ b/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs @@ -55,6 +55,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator _shuffled = Shuffle(_collections, _random); } + State.Seed = state.Seed; State.Index = state.Index; } @@ -65,6 +66,11 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator public void MoveNext(Option scheduledAt) { + if (_shuffled.Length == 0) + { + return; + } + if ((State.Index + 1) % _shuffled.Length == 0) { Option tail = Current; @@ -76,14 +82,17 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator _random = new Random(State.Seed); _shuffled = Shuffle(_collections, _random); } while (!_cancellationToken.IsCancellationRequested && _collections.Count > 1 && - Current.Map(x => x.Id) == tail.Map(x => x.Id)); + _shuffled.Length > 0 && Current.Map(x => x.Id) == tail.Map(x => x.Id)); } else { State.Index++; } - State.Index %= _shuffled.Length; + if (_shuffled.Length > 0) + { + State.Index %= _shuffled.Length; + } } public Option MinimumDuration => _lazyMinimumDuration.Value; @@ -183,7 +192,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator var index = 0; if (_randomStartPoint) { - index = random.Next(0, ordered.Count - 1); + index = random.Next(0, ordered.Count); } result.Add(new OrderedCollection { Index = index, Items = ordered }); diff --git a/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs index a7d037019..96e94a393 100644 --- a/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs +++ b/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs @@ -52,6 +52,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator _shuffled = Shuffle(_mediaItems, _random); } + State.Seed = state.Seed; State.Index = state.Index; } @@ -62,6 +63,11 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator public void MoveNext(Option scheduledAt) { + if (_mediaItemCount == 0) + { + return; + } + if ((State.Index + 1) % _mediaItemCount == 0) { Option tail = Current; @@ -73,14 +79,17 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator _random = new CloneableRandom(State.Seed); _shuffled = Shuffle(_mediaItems, _random); } while (!_cancellationToken.IsCancellationRequested && _mediaItems.Count > 1 && - Current.Map(x => x.Id) == tail.Map(x => x.Id)); + _shuffled.Count > 0 && Current.Map(x => x.Id) == tail.Map(x => x.Id)); } else { State.Index++; } - State.Index %= _mediaItemCount; + if (_mediaItemCount > 0) + { + State.Index %= _mediaItemCount; + } } public Option MinimumDuration => _lazyMinimumDuration.Value;