Browse Source

improve shuffle stability when reset

pull/2816/head
Jason Dove 6 months ago
parent
commit
0593ad6c34
No known key found for this signature in database
  1. 3
      CHANGELOG.md
  2. 132
      ErsatzTV.Core.Tests/Scheduling/ShuffleInOrderCollectionEnumeratorTests.cs
  3. 15
      ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs
  4. 13
      ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

3
CHANGELOG.md

@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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:

132
ErsatzTV.Core.Tests/Scheduling/ShuffleInOrderCollectionEnumeratorTests.cs

@ -0,0 +1,132 @@ @@ -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<CollectionWithItems>
{
new(
0,
0,
"1",
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
true,
PlaybackOrder.ShuffleInOrder,
false),
new(
0,
0,
"2",
Enumerable.Range(11, 20).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().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<int>();
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<DateTimeOffset>.None);
}
seenIds.Count.ShouldBe(20);
}
[Test]
public void Should_Handle_Single_Collection()
{
var collections = new List<CollectionWithItems>
{
new(
0,
0,
"1",
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().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<int>();
for (int i = 0; i < 10; i++)
{
seenIds.Add(enumerator.Current.ValueUnsafe().Id);
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
seenIds.Count.ShouldBe(10);
seenIds.ShouldBeInOrder(SortDirection.Ascending);
}
[Test]
public void Should_Reshuffle_After_Cycle()
{
var collections = new List<CollectionWithItems>
{
new(
0,
0,
"1",
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().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<DateTimeOffset>.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<CollectionWithItems>
{
new(
0,
0,
"1",
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().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);
}
}

15
ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs

@ -55,6 +55,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -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 @@ -65,6 +66,11 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
public void MoveNext(Option<DateTimeOffset> scheduledAt)
{
if (_shuffled.Length == 0)
{
return;
}
if ((State.Index + 1) % _shuffled.Length == 0)
{
Option<MediaItem> tail = Current;
@ -76,14 +82,17 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -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<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
@ -183,7 +192,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -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 });

13
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -52,6 +52,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator @@ -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 @@ -62,6 +63,11 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
public void MoveNext(Option<DateTimeOffset> scheduledAt)
{
if (_mediaItemCount == 0)
{
return;
}
if ((State.Index + 1) % _mediaItemCount == 0)
{
Option<MediaItem> tail = Current;
@ -73,14 +79,17 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator @@ -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<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

Loading…
Cancel
Save