using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Scheduling; namespace ErsatzTV.Core.Scheduling; /// /// Weighted / fair-share distribution (issue #70). Picks a *source* by smooth weighted round-robin, /// then takes that source's next item. A source's is its /// relative share of airtime: weights 3:1 emit A A B A per rotation. /// /// Fair-share is the equal-weights degenerate case (the default, since Weight defaults to 1): /// every source airs equally often regardless of library size, so a small show loops while a /// large one works through its items. This is what distinguishes it from /// , whose balanced shuffle plays every item /// exactly once per cycle and therefore leaves airtime proportional to collection size /// (it prevents clumping, not domination). /// /// /// Deterministic and stateless: the emitted sequence is a pure function of /// (, ), /// so it restores by replay exactly like its siblings and needs no per-source persisted counters. /// /// public class WeightedShuffleCollectionEnumerator : IMediaCollectionEnumerator { /// /// Upper bound on the precomputed rotation, so a large collection paired with a low weight /// (picks needed grows as items * totalWeight / weight) can't allocate without limit. Hitting the /// clamp only means the largest source doesn't finish its items within one rotation before the /// reseed; the weight ratio itself is unaffected. /// private const int MaxCycleLength = 100_000; /// /// Bounds the avoid-an-immediate-repeat retry when a rotation wraps. Unlike /// , whose reshuffle randomizes which item leads the /// next cycle, this order's lead item is decided by weight — the heaviest source always wins the first /// pick. So when that source has a single item the lead never changes and an unbounded retry would spin /// forever. Avoiding a back-to-back repeat is a nicety; never terminating is not. /// private const int MaxReshuffleAttempts = 10; private readonly CancellationToken _cancellationToken; private readonly IList _collections; private readonly Lazy> _lazyMinimumDuration; private Random _random; private MediaItem[] _shuffled; public WeightedShuffleCollectionEnumerator( IList collections, CollectionEnumeratorState state, CancellationToken cancellationToken) { CurrentIncludeInProgramGuide = Option.None; _collections = collections; _cancellationToken = cancellationToken; int cycleLength = CycleLength(collections); if (cycleLength > 0 && state.Index >= cycleLength) { state.Index = 0; state.Seed = new Random(state.Seed).Next(); } _random = new Random(state.Seed); _shuffled = Build(_collections, _random); // computed over every source's items, not the current rotation: a rotation can be a strict subset // (see MaxCycleLength) and is rebuilt on every wrap, so caching over it would go stale _lazyMinimumDuration = new Lazy>(() => _collections .Bind(c => c.MediaItems) .Bind(i => i.GetNonZeroDuration()) .OrderBy(identity) .HeadOrNone()); State = new CollectionEnumeratorState { Seed = state.Seed }; while (State.Index < state.Index) { MoveNext(Option.None); } } public void ResetState(CollectionEnumeratorState state) { // only rebuild if needed if (State.Seed != state.Seed) { _random = new Random(state.Seed); _shuffled = Build(_collections, _random); } State.Seed = state.Seed; State.Index = state.Index; } public string SchedulingContextName => "Weighted Shuffle"; public CollectionEnumeratorState State { get; } public Option Current => _shuffled.Length != 0 ? _shuffled[State.Index % _shuffled.Length] : None; public Option CurrentIncludeInProgramGuide { get; } public void MoveNext(Option scheduledAt) { if (_shuffled.Length == 0) { return; } if ((State.Index + 1) % _shuffled.Length == 0) { Option tail = Current; State.Index = 0; var attempts = 0; do { State.Seed = _random.Next(); _random = new Random(State.Seed); _shuffled = Build(_collections, _random); attempts++; // guard on the rotation, not the raw collection count: an empty source contributes nothing, // so a 2-collection/1-item rotation would otherwise burn every attempt chasing an // impossible non-repeat on every wrap } while (!_cancellationToken.IsCancellationRequested && _shuffled.Length > 1 && attempts < MaxReshuffleAttempts && Current.Map(x => x.Id) == tail.Map(x => x.Id)); } else { State.Index++; } if (_shuffled.Length > 0) { State.Index %= _shuffled.Length; } } public Option MinimumDuration => _lazyMinimumDuration.Value; public int Count => _shuffled.Length; /// /// Length of one rotation: enough picks for the source that needs the most of them to work through /// all of its items at its share of the rotation. Sources needing fewer picks loop within the /// rotation — that looping is exactly what makes equal weights mean equal airtime. /// private static int CycleLength(IList collections) { List active = ActiveSources(collections); if (active.Count == 0) { return 0; } long totalWeight = active.Sum(c => (long)EffectiveWeight(c)); var length = 0; foreach (CollectionWithItems collection in active) { // picks needed for this source to emit every item once, given it wins Weight of every totalWeight picks var needed = (int)Math.Min( MaxCycleLength, Math.Ceiling(collection.MediaItems.Count * (double)totalWeight / EffectiveWeight(collection))); length = Math.Max(length, needed); } return length; } private static List ActiveSources(IList collections) => collections.Filter(c => c.MediaItems.Count > 0).ToList(); /// /// Weight as the rotation should treat it. The write path bounds this (), /// but a row can predate that gate, so it is clamped rather than trusted: an out-of-range value must not /// drop a source from the channel (0 or negative) or overflow the weight sum inside a playout build. /// private static int EffectiveWeight(CollectionWithItems collection) => Math.Clamp(collection.Weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum); private static MediaItem[] Build(IList collections, Random random) { List active = ActiveSources(collections); if (active.Count == 0) { return []; } var sources = active .Map(c => new WeightedSource { Weight = EffectiveWeight(c), Items = OrderItems(c, random) }) .ToList(); int totalWeight = sources.Sum(s => s.Weight); int cycleLength = CycleLength(collections); var result = new List(cycleLength); for (var i = 0; i < cycleLength; i++) { // smooth weighted round-robin: every source gains its weight, the richest wins and pays the total. // Strict '>' keeps ties on the earliest source in list order, which makes the sequence deterministic. WeightedSource pick = null; foreach (WeightedSource source in sources) { source.Accumulator += source.Weight; if (pick is null || source.Accumulator > pick.Accumulator) { pick = source; } } pick.Accumulator -= totalWeight; result.Add(pick.Items[pick.Cursor % pick.Items.Length]); pick.Cursor++; } return result.ToArray(); } private static MediaItem[] OrderItems(CollectionWithItems collectionWithItems, Random random) { // A custom-ordered collection is an explicit user sequence, so it is honored rather than shuffled // (same rule as ShuffleInOrderCollectionEnumerator). Otherwise the source's items are shuffled per // rotation, which is what makes the reseed on wrap produce a different rotation. if (collectionWithItems.UseCustomOrder) { return collectionWithItems.MediaItems.ToArray(); } return Shuffle(collectionWithItems.MediaItems, random); } private static MediaItem[] Shuffle(IEnumerable list, Random random) { MediaItem[] copy = list.ToArray(); int n = copy.Length; while (n > 1) { n--; int k = random.Next(n + 1); (copy[k], copy[n]) = (copy[n], copy[k]); } return copy; } private class WeightedSource { public int Weight { get; init; } public MediaItem[] Items { get; init; } public int Cursor { get; set; } public int Accumulator { get; set; } } }