From 32fdb414fa697ea22273fdcd1119d649ec47b37d Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sat, 21 Aug 2021 12:47:22 -0500 Subject: [PATCH] add "shuffle in order" playback order for multi-collections (#338) * add "shuffle in order" option for multi-collections * use balanced shuffle instead of random --- CHANGELOG.md | 4 + .../ProgramScheduleItemCommandBase.cs | 17 ++ .../ReplaceProgramScheduleItemsHandler.cs | 2 - ErsatzTV.Core/Domain/PlaybackOrder.cs | 3 +- ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 17 ++ .../ShuffleInOrderCollectionEnumerator.cs | 196 ++++++++++++++++++ .../ShuffledMediaCollectionEnumerator.cs | 4 +- .../Plex/PlexServerApiClient.cs | 8 +- ErsatzTV/Pages/ScheduleItemsEditor.razor | 13 +- 9 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6718f023f..7b7a7ed54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- Add `Shuffle In Order` playback order for multi-collections. + - This is useful for randomizing multiple collections/shows on a single channel, while each collection maintains proper ordering (custom or chronological) + ### Fixed - Fix bug parsing ffprobe output in cultures where `.` is a group/thousands separator - This bug likely prevented ETV from scheduling correctly or working at all in those cultures diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs index 8fee48e03..90b8e3205 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs @@ -24,6 +24,23 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands IProgramScheduleItemRequest item, ProgramSchedule programSchedule) { + if (item.MultiCollectionId.HasValue) + { + switch (item.PlaybackOrder) + { + case PlaybackOrder.Chronological: + case PlaybackOrder.Random: + return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'"); + case PlaybackOrder.Shuffle: + case PlaybackOrder.ShuffleInOrder: + break; + } + } + else if (item.PlaybackOrder == PlaybackOrder.ShuffleInOrder) + { + return BaseError.New("Invalid playback order: 'Shuffle In Order'"); + } + switch (item.PlayoutMode) { case PlayoutMode.Flood: diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs index 875f91080..f6cc6015d 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs @@ -112,7 +112,5 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands int? CollectionId, int? MediaItemId, int? MultiCollectionId); - - private record CollectionKeyOrder(CollectionKey Key, PlaybackOrder PlaybackOrder); } } diff --git a/ErsatzTV.Core/Domain/PlaybackOrder.cs b/ErsatzTV.Core/Domain/PlaybackOrder.cs index 34f1ed99d..c5fd52621 100644 --- a/ErsatzTV.Core/Domain/PlaybackOrder.cs +++ b/ErsatzTV.Core/Domain/PlaybackOrder.cs @@ -4,6 +4,7 @@ { Chronological = 1, Random = 2, - Shuffle = 3 + Shuffle = 3, + ShuffleInOrder = 4 } } diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index ff058c8dd..060766025 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -567,6 +567,10 @@ namespace ErsatzTV.Core.Scheduling return new ShuffledMediaCollectionEnumerator( await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey), state); + case PlaybackOrder.ShuffleInOrder: + return new ShuffleInOrderCollectionEnumerator( + await GetCollectionItemsForShuffleInOrder(collectionKey), + state); default: // TODO: handle this error case differently? return new RandomizedMediaCollectionEnumerator(mediaItems, state); @@ -593,6 +597,19 @@ namespace ErsatzTV.Core.Scheduling : mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList(); } + private async Task> GetCollectionItemsForShuffleInOrder(CollectionKey collectionKey) + { + var result = new List(); + + if (collectionKey.MultiCollectionId != null) + { + result = await _mediaCollectionRepository.GetMultiCollectionCollections( + collectionKey.MultiCollectionId.Value); + } + + return result; + } + private static string DisplayTitle(MediaItem mediaItem) { switch (mediaItem) diff --git a/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs new file mode 100644 index 000000000..c105636bc --- /dev/null +++ b/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Scheduling; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Scheduling +{ + public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator + { + private readonly IList _collections; + private readonly int _mediaItemCount; + private Random _random; + private IList _shuffled; + + public ShuffleInOrderCollectionEnumerator( + IList collections, + CollectionEnumeratorState state) + { + _collections = collections; + _mediaItemCount = collections.Sum(c => c.MediaItems.Count); + + if (state.Index >= _mediaItemCount) + { + state.Index = 0; + state.Seed = new Random(state.Seed).Next(); + } + + _random = new Random(state.Seed); + _shuffled = Shuffle(_collections, _random); + + State = new CollectionEnumeratorState { Seed = state.Seed }; + while (State.Index < state.Index) + { + MoveNext(); + } + } + + public CollectionEnumeratorState State { get; } + + public Option Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None; + + public void MoveNext() + { + if ((State.Index + 1) % _shuffled.Count == 0) + { + Option tail = Current; + + State.Index = 0; + do + { + State.Seed = _random.Next(); + _random = new Random(State.Seed); + _shuffled = Shuffle(_collections, _random); + } while (_collections.Count > 1 && Current == tail); + } + else + { + State.Index++; + } + + State.Index %= _shuffled.Count; + } + + private IList Shuffle(IList collections, Random random) + { + // based on https://keyj.emphy.de/balanced-shuffle/ + + var orderedCollections = collections + .Filter(c => c.ScheduleAsGroup) + .Map(c => new OrderedCollection { Index = 0, Items = OrderItems(c) }) + .ToList(); + + if (collections.Any(c => !c.ScheduleAsGroup)) + { + orderedCollections.Add( + new OrderedCollection + { + Index = 0, + Items = Shuffle( + collections.Filter(c => !c.ScheduleAsGroup).SelectMany(c => c.MediaItems.Map(Some)), + random) + }); + } + + List filled = Fill(orderedCollections, random); + + var result = new List(); + for (var i = 0; i < filled[0].Items.Count; i++) + { + var batch = filled.Select(collection => collection.Items[i]).ToList(); + foreach (Option maybeItem in Shuffle(batch, random)) + { + result.AddRange(maybeItem); + } + } + + return result; + } + + private List Fill(List orderedCollections, Random random) + { + var result = new List(); + int maxLength = orderedCollections.Max(c => c.Items.Count); + + foreach (OrderedCollection collection in orderedCollections) + { + var items = new Queue>(collection.Items); + var spaces = new Queue>( + Range(0, maxLength - collection.Items.Count).Map(_ => Option.None).ToList()); + + Queue> smaller = collection.Items.Count < maxLength - collection.Items.Count + ? items + : spaces; + Queue> larger = collection.Items.Count < maxLength - collection.Items.Count + ? spaces + : items; + + var ordered = new List>(); + + int k = smaller.Count; + while (k > 0) + { + int n = maxLength - ordered.Count; + + // compute optimal length +/- 10% + double optimalLength = n / (double)k + (random.NextDouble() - 0.5) / 5.0; + int r = Math.Clamp((int)optimalLength, 1, maxLength - k + 1); + ordered.Add(smaller.Dequeue()); + for (var i = 0; i < r - 1; i++) + { + ordered.Add(larger.Dequeue()); + } + + k--; + } + + if (smaller.Any()) + { + ordered.AddRange(smaller); + } + + if (larger.Any()) + { + ordered.AddRange(larger); + } + + int offset = random.Next(ordered.Count); + result.Add( + new OrderedCollection + { + Index = 0, + Items = ordered.Skip(offset).Concat(ordered.Take(offset)).ToList() + }); + } + + return result; + } + + private static IList> OrderItems(CollectionWithItems collectionWithItems) + { + if (collectionWithItems.UseCustomOrder) + { + return collectionWithItems.MediaItems.Map(Some).ToList(); + } + + return collectionWithItems.MediaItems + .OrderBy(identity, new ChronologicalMediaComparer()) + .Map(Some) + .ToList(); + } + + private static IList> Shuffle(IEnumerable> list, Random random) + { + Option[] 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 OrderedCollection + { + public int Index { get; set; } + public IList> Items { get; set; } + } + } +} diff --git a/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs index 4a33b6861..1fd36021b 100644 --- a/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs +++ b/ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs @@ -73,9 +73,7 @@ namespace ErsatzTV.Core.Scheduling { n--; int k = random.Next(n + 1); - GroupedMediaItem value = copy[k]; - copy[k] = copy[n]; - copy[n] = value; + (copy[k], copy[n]) = (copy[n], copy[k]); } return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount); diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index f91b826d7..0678c8733 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; using System.Xml.Serialization; using ErsatzTV.Core; @@ -35,7 +36,12 @@ namespace ErsatzTV.Infrastructure.Plex { try { - IPlexServerApi service = RestService.For(connection.Uri); + IPlexServerApi service = RestService.For( + new HttpClient + { + BaseAddress = new Uri(connection.Uri), + Timeout = TimeSpan.FromSeconds(10) + }); List directory = await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory); return directory diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor index 840def2bd..5f99bef22 100644 --- a/ErsatzTV/Pages/ScheduleItemsEditor.razor +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -145,10 +145,17 @@ SearchFunc="@SearchArtists" ToStringFunc="@(s => s?.Name)"/> } - - @foreach (PlaybackOrder playbackOrder in Enum.GetValues()) + + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.MultiCollection) { - @playbackOrder + Shuffle + Shuffle In Order + } + else + { + Chronological + Random + Shuffle }