Browse Source

fix: always randomize start points (#2949)

* fix: always randomize start points for all collections the first time they are used

* fixes
pull/2950/head
Jason Dove 2 weeks ago committed by GitHub
parent
commit
0d692e7088
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 3
      ErsatzTV.Core/Domain/CollectionEnumeratorState.cs
  3. 3
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutShuffledMediaCollectionEnumerator.cs
  4. 9
      ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
  5. 12
      ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
  6. 8
      ErsatzTV.Core/Scheduling/OrderedScheduleItemsEnumerator.cs
  7. 15
      ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs
  8. 28
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  9. 8
      ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs
  10. 8
      ErsatzTV.Core/Scheduling/RandomizedRotatingMediaCollectionEnumerator.cs
  11. 8
      ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs
  12. 5
      ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs
  13. 5
      ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
  14. 3
      ErsatzTV.Core/Scheduling/ShuffledScheduleItemsEnumerator.cs
  15. 7060
      ErsatzTV.Infrastructure.MySql/Migrations/20260722002414_Add_CollectionEnumeratorState_Started.Designer.cs
  16. 57
      ErsatzTV.Infrastructure.MySql/Migrations/20260722002414_Add_CollectionEnumeratorState_Started.cs
  17. 9
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  18. 6887
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260722002327_Add_CollectionEnumeratorState_Started.Designer.cs
  19. 57
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260722002327_Add_CollectionEnumeratorState_Started.cs
  20. 9
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  21. 3
      ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

2
CHANGELOG.md

@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Future periodic scans will update titles in ETV automatically (deep scans will not be required to update titles) - Future periodic scans will update titles in ETV automatically (deep scans will not be required to update titles)
- Randomize start points on playlist items in classic schedules when setting is enabled - Randomize start points on playlist items in classic schedules when setting is enabled
- Playlists ignored this setting in earlier builds - Playlists ignored this setting in earlier builds
- Always randomize start points for all collections the first time they are used
- Previously, only collections scheduled during the first day of a playout build had their start points randomized
## [26.6.0] - 2026-07-09 ## [26.6.0] - 2026-07-09
### Added ### Added

3
ErsatzTV.Core/Domain/CollectionEnumeratorState.cs

@ -4,5 +4,6 @@ public class CollectionEnumeratorState
{ {
public int Seed { get; set; } public int Seed { get; set; }
public int Index { get; set; } public int Index { get; set; }
public CollectionEnumeratorState Clone() => new() { Seed = Seed, Index = Index }; public bool Started { get; set; }
public CollectionEnumeratorState Clone() => new() { Seed = Seed, Index = Index, Started = Started };
} }

3
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutShuffledMediaCollectionEnumerator.cs

@ -35,6 +35,7 @@ public class BlockPlayoutShuffledMediaCollectionEnumerator : IMediaCollectionEnu
{ {
State.Seed = state.Seed; State.Seed = state.Seed;
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
_shuffled = Shuffle(_mediaItems); _shuffled = Shuffle(_mediaItems);
} }
@ -54,6 +55,8 @@ public class BlockPlayoutShuffledMediaCollectionEnumerator : IMediaCollectionEnu
{ {
_shuffled = Shuffle(_mediaItems); _shuffled = Shuffle(_mediaItems);
} }
State.Started = true;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

9
ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs

@ -22,7 +22,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
_lazyMediaItemGroupSize = new Lazy<Dictionary<int, int>>(CalculateMediaItemGroupSizes); _lazyMediaItemGroupSize = new Lazy<Dictionary<int, int>>(CalculateMediaItemGroupSizes);
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
if (state.Index >= _sortedMediaItems.Count) if (state.Index >= _sortedMediaItems.Count)
{ {
@ -36,9 +36,12 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
} }
} }
public void ResetState(CollectionEnumeratorState state) => public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter in chronological // seed doesn't matter in chronological
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Chronological"; public string SchedulingContextName => "Chronological";
@ -53,6 +56,8 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
{ {
State.Index = (State.Index + 1) % _sortedMediaItems.Count; State.Index = (State.Index + 1) % _sortedMediaItems.Count;
} }
State.Started = true;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

12
ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs

@ -25,16 +25,19 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() => _lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() =>
_sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone()); _sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index) while (State.Index < state.Index)
{ {
MoveNext(Option<DateTimeOffset>.None); MoveNext(Option<DateTimeOffset>.None);
} }
} }
public void ResetState(CollectionEnumeratorState state) => public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter here // seed doesn't matter here
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Custom Order"; public string SchedulingContextName => "Custom Order";
@ -43,8 +46,11 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
public Option<MediaItem> Current => _sortedMediaItems.Count != 0 ? _sortedMediaItems[State.Index] : None; public Option<MediaItem> Current => _sortedMediaItems.Count != 0 ? _sortedMediaItems[State.Index] : None;
public Option<bool> CurrentIncludeInProgramGuide { get; } public Option<bool> CurrentIncludeInProgramGuide { get; }
public void MoveNext(Option<DateTimeOffset> scheduledAt) => public void MoveNext(Option<DateTimeOffset> scheduledAt)
{
State.Index = (State.Index + 1) % _sortedMediaItems.Count; State.Index = (State.Index + 1) % _sortedMediaItems.Count;
State.Started = true;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

8
ErsatzTV.Core/Scheduling/OrderedScheduleItemsEnumerator.cs

@ -13,7 +13,7 @@ public class OrderedScheduleItemsEnumerator : IScheduleItemsEnumerator
{ {
_sortedScheduleItems = scheduleItems.OrderBy(i => i.Index).ToList(); _sortedScheduleItems = scheduleItems.OrderBy(i => i.Index).ToList();
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
if (state.Index >= _sortedScheduleItems.Count) if (state.Index >= _sortedScheduleItems.Count)
{ {
@ -31,7 +31,11 @@ public class OrderedScheduleItemsEnumerator : IScheduleItemsEnumerator
public ProgramScheduleItem Current => _sortedScheduleItems[State.Index]; public ProgramScheduleItem Current => _sortedScheduleItems[State.Index];
public void MoveNext() => State.Index = (State.Index + 1) % _sortedScheduleItems.Count; public void MoveNext()
{
State.Index = (State.Index + 1) % _sortedScheduleItems.Count;
State.Started = true;
}
public ProgramScheduleItem Peek(int offset) => public ProgramScheduleItem Peek(int offset) =>
_sortedScheduleItems[(State.Index + offset) % _sortedScheduleItems.Count]; _sortedScheduleItems[(State.Index + offset) % _sortedScheduleItems.Count];

15
ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs

@ -31,9 +31,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
public int EnumeratorIndex { get; private set; } public int EnumeratorIndex { get; private set; }
public void ResetState(CollectionEnumeratorState state) => public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter here // seed doesn't matter here
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Playlist"; public string SchedulingContextName => "Playlist";
@ -111,6 +114,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
_sortedEnumerators = ShufflePlaylistItems(); _sortedEnumerators = ShufflePlaylistItems();
} }
} }
State.Started = true;
} }
public void SetEnumeratorIndex(int enumeratorIndex) => EnumeratorIndex = enumeratorIndex % _sortedEnumerators.Count; public void SetEnumeratorIndex(int enumeratorIndex) => EnumeratorIndex = enumeratorIndex % _sortedEnumerators.Count;
@ -134,7 +139,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
// random start points only apply to a fresh build with no saved state // random start points only apply to a fresh build with no saved state
var random = new Random(state.Seed); var random = new Random(state.Seed);
randomStartPoint = randomStartPoint && state.Index == 0; randomStartPoint = randomStartPoint && !state.Started;
// collections should share enumerators // collections should share enumerators
var enumeratorMap = new Dictionary<CollectionKey, IMediaCollectionEnumerator>(); var enumeratorMap = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
@ -173,7 +178,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
case PlaybackOrder.Chronological: case PlaybackOrder.Chronological:
if (randomStartPoint) if (randomStartPoint)
{ {
initState.Index = random.Next(0, items.Count - 1); initState.Index = items.Count > 0 ? random.Next(0, items.Count) : 0;
} }
enumerator = new ChronologicalMediaCollectionEnumerator(items, initState); enumerator = new ChronologicalMediaCollectionEnumerator(items, initState);
@ -203,7 +208,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
case PlaybackOrder.SeasonEpisode: case PlaybackOrder.SeasonEpisode:
if (randomStartPoint) if (randomStartPoint)
{ {
initState.Index = random.Next(0, items.Count - 1); initState.Index = items.Count > 0 ? random.Next(0, items.Count) : 0;
} }
enumerator = new SeasonEpisodeMediaCollectionEnumerator(items, initState); enumerator = new SeasonEpisodeMediaCollectionEnumerator(items, initState);
@ -243,7 +248,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
result._sortedEnumerators = result.ShufflePlaylistItems(); result._sortedEnumerators = result.ShufflePlaylistItems();
} }
result.State = new CollectionEnumeratorState { Seed = state.Seed }; result.State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
result.EnumeratorIndex = 0; result.EnumeratorIndex = 0;
// this was a bug when playlist enumerators were first added; shouldn't happen anymore // this was a bug when playlist enumerators were first added; shouldn't happen anymore

28
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -276,7 +276,6 @@ public class PlayoutBuilder : IPlayoutBuilder
parameters.Start, parameters.Start,
parameters.Finish, parameters.Finish,
parameters.CollectionMediaItems, parameters.CollectionMediaItems,
false,
cancellationToken); cancellationToken);
} }
@ -314,7 +313,6 @@ public class PlayoutBuilder : IPlayoutBuilder
parameters.Start, parameters.Start,
parameters.Finish, parameters.Finish,
parameters.CollectionMediaItems, parameters.CollectionMediaItems,
true,
cancellationToken); cancellationToken);
foreach (BaseError error in maybeResult.LeftToSeq()) foreach (BaseError error in maybeResult.LeftToSeq())
@ -362,7 +360,6 @@ public class PlayoutBuilder : IPlayoutBuilder
parameters.Start, parameters.Start,
parameters.Finish, parameters.Finish,
parameters.CollectionMediaItems, parameters.CollectionMediaItems,
false,
cancellationToken); cancellationToken);
} }
@ -437,7 +434,6 @@ public class PlayoutBuilder : IPlayoutBuilder
DateTimeOffset playoutStart, DateTimeOffset playoutStart,
DateTimeOffset playoutFinish, DateTimeOffset playoutFinish,
Map<CollectionKey, List<MediaItem>> collectionMediaItems, Map<CollectionKey, List<MediaItem>> collectionMediaItems,
bool randomStartPoint,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
DateTimeOffset trimBefore = playoutStart.AddHours(-4); DateTimeOffset trimBefore = playoutStart.AddHours(-4);
@ -476,7 +472,6 @@ public class PlayoutBuilder : IPlayoutBuilder
finish, finish,
collectionMediaItems, collectionMediaItems,
true, true,
randomStartPoint,
cancellationToken); cancellationToken);
foreach (BaseError error in buildResult.LeftToSeq()) foreach (BaseError error in buildResult.LeftToSeq())
@ -489,9 +484,6 @@ public class PlayoutBuilder : IPlayoutBuilder
result = r; result = r;
} }
// only randomize once (at the start of the playout)
randomStartPoint = false;
start = playout.Anchor?.NextStartOffset ?? start; start = playout.Anchor?.NextStartOffset ?? start;
finish = finish.AddDays(1); finish = finish.AddDays(1);
} }
@ -513,7 +505,6 @@ public class PlayoutBuilder : IPlayoutBuilder
playoutFinish, playoutFinish,
collectionMediaItems, collectionMediaItems,
false, false,
randomStartPoint,
cancellationToken); cancellationToken);
foreach (BaseError error in buildResult.LeftToSeq()) foreach (BaseError error in buildResult.LeftToSeq())
@ -565,7 +556,6 @@ public class PlayoutBuilder : IPlayoutBuilder
DateTimeOffset playoutFinish, DateTimeOffset playoutFinish,
Map<CollectionKey, List<MediaItem>> collectionMediaItems, Map<CollectionKey, List<MediaItem>> collectionMediaItems,
bool saveAnchorDate, bool saveAnchorDate,
bool randomStartPoint,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var random = new Random(playout.Seed); var random = new Random(playout.Seed);
@ -589,9 +579,6 @@ public class PlayoutBuilder : IPlayoutBuilder
// _logger.LogDebug("Active schedule is: {Schedule}", activeSchedule.Name); // _logger.LogDebug("Active schedule is: {Schedule}", activeSchedule.Name);
// random start points are disabled in some scenarios, so ensure it's enabled and active
randomStartPoint = randomStartPoint && activeSchedule.RandomStartPoint;
var sortedScheduleItems = activeSchedule.Items.OrderBy(i => i.Index).ToList(); var sortedScheduleItems = activeSchedule.Items.OrderBy(i => i.Index).ToList();
CollectionEnumeratorState scheduleItemsEnumeratorState = CollectionEnumeratorState scheduleItemsEnumeratorState =
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState
@ -619,7 +606,7 @@ public class PlayoutBuilder : IPlayoutBuilder
scheduleItem.MarathonShuffleGroups, scheduleItem.MarathonShuffleGroups,
scheduleItem.MarathonShuffleItems, scheduleItem.MarathonShuffleItems,
scheduleItem.MarathonBatchSize, scheduleItem.MarathonBatchSize,
randomStartPoint, activeSchedule.RandomStartPoint,
random, random,
cancellationToken); cancellationToken);
@ -640,7 +627,7 @@ public class PlayoutBuilder : IPlayoutBuilder
marathonShuffleGroups: false, marathonShuffleGroups: false,
marathonShuffleItems: false, marathonShuffleItems: false,
marathonBatchSize: null, marathonBatchSize: null,
randomStartPoint, activeSchedule.RandomStartPoint,
random, random,
cancellationToken); cancellationToken);
@ -718,7 +705,7 @@ public class PlayoutBuilder : IPlayoutBuilder
scheduleItem.MarathonShuffleGroups, scheduleItem.MarathonShuffleGroups,
scheduleItem.MarathonShuffleItems, scheduleItem.MarathonShuffleItems,
scheduleItem.MarathonBatchSize, scheduleItem.MarathonBatchSize,
randomStartPoint, activeSchedule.RandomStartPoint,
random, random,
cancellationToken); cancellationToken);
@ -1347,8 +1334,7 @@ public class PlayoutBuilder : IPlayoutBuilder
} }
} }
// index shouldn't ever be greater than zero with randomStartPoint since anchors shouldn't exist, but randomStartPoint = randomStartPoint && !state.Started;
randomStartPoint = randomStartPoint && state.Index == 0;
switch (playbackOrder) switch (playbackOrder)
{ {
@ -1358,7 +1344,7 @@ public class PlayoutBuilder : IPlayoutBuilder
state = new CollectionEnumeratorState state = new CollectionEnumeratorState
{ {
Seed = state.Seed, Seed = state.Seed,
Index = random.Next(0, mediaItems.Count - 1) Index = mediaItems.Count > 0 ? random.Next(0, mediaItems.Count) : 0
}; };
} }
@ -1369,7 +1355,7 @@ public class PlayoutBuilder : IPlayoutBuilder
state = new CollectionEnumeratorState state = new CollectionEnumeratorState
{ {
Seed = state.Seed, Seed = state.Seed,
Index = random.Next(0, mediaItems.Count - 1) Index = mediaItems.Count > 0 ? random.Next(0, mediaItems.Count) : 0
}; };
} }
@ -1383,7 +1369,7 @@ public class PlayoutBuilder : IPlayoutBuilder
collectionKey, collectionKey,
cancellationToken), cancellationToken),
state, state,
activeSchedule.RandomStartPoint, randomStartPoint,
cancellationToken); cancellationToken);
case PlaybackOrder.MultiEpisodeShuffle when case PlaybackOrder.MultiEpisodeShuffle when
collectionKey.CollectionType == CollectionType.TelevisionShow && collectionKey.CollectionType == CollectionType.TelevisionShow &&

8
ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs

@ -21,7 +21,7 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
_mediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone()); _mediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
_random = new Random(state.Seed); _random = new Random(state.Seed);
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
// we want to move at least once so we start with a random item and not the first // we want to move at least once so we start with a random item and not the first
// because _index defaults to 0 // because _index defaults to 0
if (State.Index == state.Index) if (State.Index == state.Index)
@ -37,9 +37,12 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
} }
} }
public void ResetState(CollectionEnumeratorState state) => public void ResetState(CollectionEnumeratorState state)
{
// seed never changes here, no need to reset // seed never changes here, no need to reset
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Random"; public string SchedulingContextName => "Random";
@ -57,6 +60,7 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
_index = _random.Next() % _mediaItems.Count; _index = _random.Next() % _mediaItems.Count;
State.Index++; State.Index++;
State.Started = true;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

8
ErsatzTV.Core/Scheduling/RandomizedRotatingMediaCollectionEnumerator.cs

@ -45,7 +45,7 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume
_groupNumber = 0; _groupNumber = 0;
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
// we want to move at least once so we start with a random item and not the first // we want to move at least once so we start with a random item and not the first
// because _index defaults to 0 // because _index defaults to 0
if (State.Index == state.Index) if (State.Index == state.Index)
@ -61,9 +61,12 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume
} }
} }
public void ResetState(CollectionEnumeratorState state) => public void ResetState(CollectionEnumeratorState state)
{
// seed never changes here, no need to reset // seed never changes here, no need to reset
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Random Rotating"; public string SchedulingContextName => "Random Rotating";
@ -102,6 +105,7 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume
int itemNumber = nextRandom % _groupMedia[groups[_groupNumber]].Count; int itemNumber = nextRandom % _groupMedia[groups[_groupNumber]].Count;
_index = _groupMedia[groups[_groupNumber]][itemNumber]; _index = _groupMedia[groups[_groupNumber]][itemNumber];
State.Index++; State.Index++;
State.Started = true;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

8
ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs

@ -21,7 +21,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() => _lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() =>
_sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone()); _sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
if (state.Index >= _sortedMediaItems.Count) if (state.Index >= _sortedMediaItems.Count)
{ {
@ -35,9 +35,12 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
} }
} }
public void ResetState(CollectionEnumeratorState state) => public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter here // seed doesn't matter here
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Season, Episode"; public string SchedulingContextName => "Season, Episode";
@ -54,6 +57,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
} }
State.Index = (State.Index + 1) % _sortedMediaItems.Count; State.Index = (State.Index + 1) % _sortedMediaItems.Count;
State.Started = true;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

5
ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs

@ -39,7 +39,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
new Lazy<Option<TimeSpan>>(() => new Lazy<Option<TimeSpan>>(() =>
_shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone()); _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index) while (State.Index < state.Index)
{ {
MoveNext(Option<DateTimeOffset>.None); MoveNext(Option<DateTimeOffset>.None);
@ -57,6 +57,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
State.Seed = state.Seed; State.Seed = state.Seed;
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
} }
public string SchedulingContextName => "Shuffle in Order"; public string SchedulingContextName => "Shuffle in Order";
@ -95,6 +96,8 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
{ {
State.Index %= _shuffled.Length; State.Index %= _shuffled.Length;
} }
State.Started = true;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

5
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -36,7 +36,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
new Lazy<Option<TimeSpan>>(() => new Lazy<Option<TimeSpan>>(() =>
_shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone()); _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index) while (State.Index < state.Index)
{ {
MoveNext(Option<DateTimeOffset>.None); MoveNext(Option<DateTimeOffset>.None);
@ -54,6 +54,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
State.Seed = state.Seed; State.Seed = state.Seed;
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
} }
public string SchedulingContextName => "Shuffle"; public string SchedulingContextName => "Shuffle";
@ -92,6 +93,8 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
{ {
State.Index %= _mediaItemCount; State.Index %= _mediaItemCount;
} }
State.Started = true;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

3
ErsatzTV.Core/Scheduling/ShuffledScheduleItemsEnumerator.cs

@ -26,7 +26,7 @@ public class ShuffledScheduleItemsEnumerator : IScheduleItemsEnumerator
_random = new CloneableRandom(state.Seed); _random = new CloneableRandom(state.Seed);
_shuffled = Shuffle(_scheduleItems, _random); _shuffled = Shuffle(_scheduleItems, _random);
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index) while (State.Index < state.Index)
{ {
MoveNext(); MoveNext();
@ -57,6 +57,7 @@ public class ShuffledScheduleItemsEnumerator : IScheduleItemsEnumerator
} }
State.Index %= _scheduleItemsCount; State.Index %= _scheduleItemsCount;
State.Started = true;
} }
public ProgramScheduleItem Peek(int offset) public ProgramScheduleItem Peek(int offset)

7060
ErsatzTV.Infrastructure.MySql/Migrations/20260722002414_Add_CollectionEnumeratorState_Started.Designer.cs generated

File diff suppressed because it is too large Load Diff

57
ErsatzTV.Infrastructure.MySql/Migrations/20260722002414_Add_CollectionEnumeratorState_Started.cs

@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_CollectionEnumeratorState_Started : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "ScheduleItemsEnumeratorState",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "FillGroupEnumeratorState",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "CollectionEnumeratorState",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
// treat all existing enumerators as started so upgrades never yank an in-progress
// enumerator to a random start point; only enumerators created after this get a random start
migrationBuilder.Sql("UPDATE `ScheduleItemsEnumeratorState` SET `Started` = 1");
migrationBuilder.Sql("UPDATE `FillGroupEnumeratorState` SET `Started` = 1");
migrationBuilder.Sql("UPDATE `CollectionEnumeratorState` SET `Started` = 1");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Started",
table: "ScheduleItemsEnumeratorState");
migrationBuilder.DropColumn(
name: "Started",
table: "FillGroupEnumeratorState");
migrationBuilder.DropColumn(
name: "Started",
table: "CollectionEnumeratorState");
}
}
}

9
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -5207,6 +5207,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b2.Property<int>("Seed") b2.Property<int>("Seed")
.HasColumnType("int"); .HasColumnType("int");
b2.Property<bool>("Started")
.HasColumnType("tinyint(1)");
b2.HasKey("PlayoutAnchorPlayoutId"); b2.HasKey("PlayoutAnchorPlayoutId");
b2.ToTable("ScheduleItemsEnumeratorState", (string)null); b2.ToTable("ScheduleItemsEnumeratorState", (string)null);
@ -5355,6 +5358,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b1.Property<int>("Seed") b1.Property<int>("Seed")
.HasColumnType("int"); .HasColumnType("int");
b1.Property<bool>("Started")
.HasColumnType("tinyint(1)");
b1.HasKey("PlayoutProgramScheduleAnchorId"); b1.HasKey("PlayoutProgramScheduleAnchorId");
b1.ToTable("CollectionEnumeratorState", (string)null); b1.ToTable("CollectionEnumeratorState", (string)null);
@ -5405,6 +5411,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b1.Property<int>("Seed") b1.Property<int>("Seed")
.HasColumnType("int"); .HasColumnType("int");
b1.Property<bool>("Started")
.HasColumnType("tinyint(1)");
b1.HasKey("PlayoutScheduleItemFillGroupIndexId"); b1.HasKey("PlayoutScheduleItemFillGroupIndexId");
b1.ToTable("FillGroupEnumeratorState", (string)null); b1.ToTable("FillGroupEnumeratorState", (string)null);

6887
ErsatzTV.Infrastructure.Sqlite/Migrations/20260722002327_Add_CollectionEnumeratorState_Started.Designer.cs generated

File diff suppressed because it is too large Load Diff

57
ErsatzTV.Infrastructure.Sqlite/Migrations/20260722002327_Add_CollectionEnumeratorState_Started.cs

@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_CollectionEnumeratorState_Started : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "ScheduleItemsEnumeratorState",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "FillGroupEnumeratorState",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "CollectionEnumeratorState",
type: "INTEGER",
nullable: false,
defaultValue: false);
// treat all existing enumerators as started so upgrades never yank an in-progress
// enumerator to a random start point; only enumerators created after this get a random start
migrationBuilder.Sql("""UPDATE "ScheduleItemsEnumeratorState" SET "Started" = 1""");
migrationBuilder.Sql("""UPDATE "FillGroupEnumeratorState" SET "Started" = 1""");
migrationBuilder.Sql("""UPDATE "CollectionEnumeratorState" SET "Started" = 1""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Started",
table: "ScheduleItemsEnumeratorState");
migrationBuilder.DropColumn(
name: "Started",
table: "FillGroupEnumeratorState");
migrationBuilder.DropColumn(
name: "Started",
table: "CollectionEnumeratorState");
}
}
}

9
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -5034,6 +5034,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b2.Property<int>("Seed") b2.Property<int>("Seed")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b2.Property<bool>("Started")
.HasColumnType("INTEGER");
b2.HasKey("PlayoutAnchorPlayoutId"); b2.HasKey("PlayoutAnchorPlayoutId");
b2.ToTable("ScheduleItemsEnumeratorState", (string)null); b2.ToTable("ScheduleItemsEnumeratorState", (string)null);
@ -5182,6 +5185,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b1.Property<int>("Seed") b1.Property<int>("Seed")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b1.Property<bool>("Started")
.HasColumnType("INTEGER");
b1.HasKey("PlayoutProgramScheduleAnchorId"); b1.HasKey("PlayoutProgramScheduleAnchorId");
b1.ToTable("CollectionEnumeratorState", (string)null); b1.ToTable("CollectionEnumeratorState", (string)null);
@ -5232,6 +5238,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b1.Property<int>("Seed") b1.Property<int>("Seed")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b1.Property<bool>("Started")
.HasColumnType("INTEGER");
b1.HasKey("PlayoutScheduleItemFillGroupIndexId"); b1.HasKey("PlayoutScheduleItemFillGroupIndexId");
b1.ToTable("FillGroupEnumeratorState", (string)null); b1.ToTable("FillGroupEnumeratorState", (string)null);

3
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

@ -83,7 +83,7 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
new Lazy<Option<TimeSpan>>(() => new Lazy<Option<TimeSpan>>(() =>
_shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone()); _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index) while (State.Index < state.Index)
{ {
MoveNext(Option<DateTimeOffset>.None); MoveNext(Option<DateTimeOffset>.None);
@ -100,6 +100,7 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
} }
State.Index = state.Index; State.Index = state.Index;
State.Started = state.Started;
} }
public string SchedulingContextName => "Multi-Episode Shuffle"; public string SchedulingContextName => "Multi-Episode Shuffle";

Loading…
Cancel
Save