From f5aa2fcac851302f2b3fb6efbf667ca52393092e Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sun, 9 Oct 2022 10:49:07 -0500 Subject: [PATCH] add multi-episode shuffle playout order (#987) --- CHANGELOG.md | 10 + .../ProgramScheduleItemCommandBase.cs | 1 + .../Scheduling/PlayoutBuilderTests.cs | 74 +++++++ .../Scheduling/ScheduleIntegrationTests.cs | 4 + ErsatzTV.Core/Domain/PlaybackOrder.cs | 3 +- ErsatzTV.Core/FileSystemLayout.cs | 5 + ...isodeShuffleCollectionEnumeratorFactory.cs | 11 + ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 50 ++++- .../Data/Repositories/TelevisionRepository.cs | 2 + .../ErsatzTV.Infrastructure.csproj | 1 + ...MultiEpisodeShuffleCollectionEnumerator.cs | 199 ++++++++++++++++++ ...isodeShuffleCollectionEnumeratorFactory.cs | 23 ++ ErsatzTV/ErsatzTV.csproj | 1 + ErsatzTV/Pages/ScheduleItemsEditor.razor | 6 + .../Resources/Scripts/_threePartEpisodes.lua | 13 ++ .../RunOnce/ResourceExtractorService.cs | 20 ++ ErsatzTV/Startup.cs | 13 ++ 17 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs create mode 100644 ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs create mode 100644 ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs create mode 100644 ErsatzTV/Resources/Scripts/_threePartEpisodes.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2d93fc0..f2acdf77f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `all_artists`: a list of additional artists from the music video's sidecar NFO metadata file - `duration`: the timespan duration of the music video, which can be used to calculate timing of additional subtitles - `stream_seek`: the timespan that ffmpeg will seek into the media item before beginning playback +- Add `Multi-Episode Shuffle` playout order for `Television Show` schedule items + - The purpose of this playout order is to improve randomization for shows that normally have intro, multiple episodes, and outro + - This playout order requires splitting the parts into individual files (e.g. splitting `s01e01-03.mkv` into `s01e01.mkv`, `s01e02.mkv` and `s01e03.mkv`) + - This playout order requires a lua script in the config subfolder `scripts/multi-episode-shuffle` + - The lua script should be named for the television show's guid, e.g. `tvdb_12345.lua` or `imdb_tt123456789.lua` + - The script defines the number of parts that each un-split file typically contains + - The script also defines a function to map each episode to a part number (or no part number i.e. `nil` if an episode has not been split) + - All groups of part numbers (i.e. all part 1s, all part 2s) will be shuffled + - The playout order will then schedule a random part 1 followed by a random part 2, etc + - Un-split (`nil`) episodes will be randomly placed between re-combined parts (e.g. part1, part2, part3, un-split, part1, part2, part3) ### Changed - No longer place watermarks within content by default (e.g. within 4:3 content padded to a 16:9 resolution) diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs index 8d353bcf7..58be00679 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs @@ -55,6 +55,7 @@ public abstract class ProgramScheduleItemCommandBase { case PlaybackOrder.Chronological: case PlaybackOrder.Random: + case PlaybackOrder.MultiEpisodeShuffle: return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'"); case PlaybackOrder.Shuffle: case PlaybackOrder.ShuffleInOrder: diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs index b868f7681..20cf9bd2f 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs @@ -1,7 +1,9 @@ using Destructurama; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Tests.Fakes; using FluentAssertions; @@ -547,11 +549,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -639,11 +645,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -779,11 +789,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -877,11 +891,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -984,11 +1002,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -1084,11 +1106,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -1188,11 +1214,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -1297,11 +1327,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -1395,11 +1429,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -1504,11 +1542,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -1624,11 +1666,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -1736,11 +1782,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -1808,11 +1858,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -2017,11 +2071,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(24); @@ -2385,11 +2443,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -2492,11 +2554,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -2599,11 +2665,15 @@ public class PlayoutBuilderTests var configRepo = new Mock(); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, fakeRepository, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); DateTimeOffset start = HoursAfterMidnight(0); @@ -2679,11 +2749,15 @@ public class PlayoutBuilderTests var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems))); var televisionRepo = new FakeTelevisionRepository(); var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); var builder = new PlayoutBuilder( configRepo.Object, collectionRepo, televisionRepo, artistRepo.Object, + factory.Object, + localFileSystem.Object, _logger); var items = new List { Flood(mediaCollection, playbackOrder) }; diff --git a/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs index a8039996f..8c1334632 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs @@ -1,6 +1,8 @@ using Dapper; using Destructurama; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; @@ -153,6 +155,8 @@ public class ScheduleIntegrationTests new MediaCollectionRepository(new Mock().Object, factory), new TelevisionRepository(factory), new ArtistRepository(factory), + new Mock().Object, + new Mock().Object, provider.GetRequiredService>()); for (var i = 0; i <= (24 * 4); i++) diff --git a/ErsatzTV.Core/Domain/PlaybackOrder.cs b/ErsatzTV.Core/Domain/PlaybackOrder.cs index 47d5e89e6..67b215d77 100644 --- a/ErsatzTV.Core/Domain/PlaybackOrder.cs +++ b/ErsatzTV.Core/Domain/PlaybackOrder.cs @@ -5,5 +5,6 @@ public enum PlaybackOrder Chronological = 1, Random = 2, Shuffle = 3, - ShuffleInOrder = 4 + ShuffleInOrder = 4, + MultiEpisodeShuffle = 5 } diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index 9fb271eae..6929232d5 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -50,4 +50,9 @@ public static class FileSystemLayout public static readonly string MusicVideoCreditsTemplatesFolder = Path.Combine(TemplatesFolder, "music-video-credits"); + + public static readonly string ScriptsFolder = Path.Combine(AppDataFolder, "scripts"); + + public static readonly string MultiEpisodeShuffleTemplatesFolder = + Path.Combine(ScriptsFolder, "multi-episode-shuffle"); } diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs b/ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs new file mode 100644 index 000000000..a5d301f8c --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Scheduling; + +public interface IMultiEpisodeShuffleCollectionEnumeratorFactory +{ + IMediaCollectionEnumerator Create( + string luaTemplatePath, + IList mediaItems, + CollectionEnumeratorState state); +} diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index adb585183..cc5ea5bc3 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Scheduling; using LanguageExt.UnsafeValueAccess; @@ -14,6 +15,8 @@ public class PlayoutBuilder : IPlayoutBuilder { private static readonly Random Random = new(); private readonly IArtistRepository _artistRepository; + private readonly IMultiEpisodeShuffleCollectionEnumeratorFactory _multiEpisodeFactory; + private readonly ILocalFileSystem _localFileSystem; private readonly IConfigElementRepository _configElementRepository; private readonly ILogger _logger; private readonly IMediaCollectionRepository _mediaCollectionRepository; @@ -24,12 +27,16 @@ public class PlayoutBuilder : IPlayoutBuilder IMediaCollectionRepository mediaCollectionRepository, ITelevisionRepository televisionRepository, IArtistRepository artistRepository, + IMultiEpisodeShuffleCollectionEnumeratorFactory multiEpisodeFactory, + ILocalFileSystem localFileSystem, ILogger logger) { _configElementRepository = configElementRepository; _mediaCollectionRepository = mediaCollectionRepository; _televisionRepository = televisionRepository; _artistRepository = artistRepository; + _multiEpisodeFactory = multiEpisodeFactory; + _localFileSystem = localFileSystem; _logger = logger; } @@ -746,15 +753,50 @@ public class PlayoutBuilder : IPlayoutBuilder return new ChronologicalMediaCollectionEnumerator(mediaItems, state); case PlaybackOrder.Random: return new RandomizedMediaCollectionEnumerator(mediaItems, state); - case PlaybackOrder.Shuffle: - return new ShuffledMediaCollectionEnumerator( - await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey), - state); case PlaybackOrder.ShuffleInOrder: return new ShuffleInOrderCollectionEnumerator( await GetCollectionItemsForShuffleInOrder(collectionKey), state, playout.ProgramSchedule.RandomStartPoint); + case PlaybackOrder.MultiEpisodeShuffle when + collectionKey.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow && + collectionKey.MediaItemId.HasValue: + foreach (Show show in await _televisionRepository.GetShow(collectionKey.MediaItemId.Value)) + { + foreach (MetadataGuid guid in show.ShowMetadata.Map(sm => sm.Guids).Flatten()) + { + string luaTemplatePath = Path.ChangeExtension( + Path.Combine( + FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, + guid.Guid.Replace("://", "_")), + "lua"); + _logger.LogDebug("Checking for lua template at {Path}", luaTemplatePath); + if (_localFileSystem.FileExists(luaTemplatePath)) + { + _logger.LogDebug("Found lua template at {Path}", luaTemplatePath); + try + { + return _multiEpisodeFactory.Create(luaTemplatePath, mediaItems, state); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to initialize multi-episode shuffle; falling back to normal shuffle"); + } + } + } + } + + // fall back to shuffle if show or template cannot be found + goto case PlaybackOrder.Shuffle; + + // fall back to shuffle when television show isn't selected + case PlaybackOrder.MultiEpisodeShuffle: + case PlaybackOrder.Shuffle: + return new ShuffledMediaCollectionEnumerator( + await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey), + state); default: // TODO: handle this error case differently? return new RandomizedMediaCollectionEnumerator(mediaItems, state); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index 233cdf23b..ab252cc1d 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -67,6 +67,8 @@ public class TelevisionRepository : ITelevisionRepository .Include(s => s.ShowMetadata) .ThenInclude(sm => sm.Actors) .ThenInclude(a => a.Artwork) + .Include(s => s.ShowMetadata) + .ThenInclude(sm => sm.Guids) .OrderBy(s => s.Id) .SingleOrDefaultAsync() .Map(Optional); diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj index 3f19f9c1c..5c56624e4 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj @@ -24,6 +24,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs b/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs new file mode 100644 index 000000000..8e38e1b22 --- /dev/null +++ b/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs @@ -0,0 +1,199 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling; +using Microsoft.Extensions.Logging; +using NLua; + +namespace ErsatzTV.Infrastructure.Scheduling; + +public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerator +{ + private readonly ILogger _logger; + private readonly int _mediaItemCount; + private readonly Dictionary> _mediaItemGroups; + private readonly List _ungrouped; + private CloneableRandom _random; + private IList _shuffled; + + public MultiEpisodeShuffleCollectionEnumerator( + IList mediaItems, + CollectionEnumeratorState state, + string templateFile, + ILogger logger) + { + _logger = logger; + using var lua = new Lua(); + lua.DoFile(templateFile); + + var numGroups = (int)(double)lua["numParts"]; + + _mediaItemGroups = new Dictionary>(); + for (var i = 1; i <= numGroups; i++) + { + _mediaItemGroups.Add(i, new List()); + } + + _ungrouped = new List(); + _mediaItemCount = mediaItems.Count; + + var groupForEpisode = (LuaFunction)lua["partNumberForEpisode"]; + IList validEpisodes = mediaItems + .OfType() + .Filter(e => e.Season is not null && e.EpisodeMetadata is not null && e.EpisodeMetadata.Count == 1) + .ToList(); + foreach (Episode episode in validEpisodes) + { + // prep lua params + int seasonNumber = episode.Season.SeasonNumber; + int episodeNumber = episode.EpisodeMetadata[0].EpisodeNumber; + + // call the lua fn + object[] result = groupForEpisode.Call(seasonNumber, episodeNumber); + + // if we get a group number back, use it + if (result[0] is long groupNumber) + { + _mediaItemGroups[(int)groupNumber].Add(episode); + } + else + { + _ungrouped.Add(episode); + } + } + + // add everything else + _ungrouped.AddRange(mediaItems.Except(validEpisodes)); + + if (state.Index >= _mediaItemCount) + { + state.Index = 0; + state.Seed = new Random(state.Seed).Next(); + } + + _random = new CloneableRandom(state.Seed); + _shuffled = Shuffle(_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) % _mediaItemCount == 0) + { + Option tail = Current; + + State.Index = 0; + do + { + State.Seed = _random.Next(); + _random = new CloneableRandom(State.Seed); + _shuffled = Shuffle(_random); + } while (_mediaItemCount > 1 && Current == tail); + } + else + { + State.Index++; + } + + State.Index %= _mediaItemCount; + } + + public Option Peek(int offset) + { + if (offset == 0) + { + return Current; + } + + if ((State.Index + offset) % _mediaItemCount == 0) + { + IList shuffled; + Option tail = Current; + + // clone the random + CloneableRandom randomCopy = _random.Clone(); + + do + { + int newSeed = randomCopy.Next(); + randomCopy = new CloneableRandom(newSeed); + shuffled = Shuffle(randomCopy); + } while (_mediaItemCount > 1 && shuffled[0] == tail); + + return shuffled.Any() ? shuffled[0] : None; + } + + return _shuffled.Any() ? _shuffled[(State.Index + offset) % _mediaItemCount] : None; + } + + private IList Shuffle(CloneableRandom random) + { + int maxGroupNumber = _mediaItemGroups.Max(a => a.Key); + var shuffledGroups = new List>(); + for (var i = 1; i <= maxGroupNumber; i++) + { + shuffledGroups.Add(Shuffle(_mediaItemGroups[i], random)); + } + + int minItems = shuffledGroups.Min(g => g.Count); + if (shuffledGroups.Any(g => g.Count != minItems)) + { + _logger.LogError("Multi Episode Groups are different sizes; shuffle will not perform correctly!"); + } + + // convert shuffled "groups" into groups that can be used for scheduling + var copy = new GroupedMediaItem[minItems + _ungrouped.Count]; + for (var i = 0; i < minItems; i++) + { + var group = new GroupedMediaItem(shuffledGroups[0][i], null); + for (var j = 1; j < shuffledGroups.Count; j++) + { + group.Additional.Add(shuffledGroups[j][i]); + } + + copy[i] = group; + } + + // convert all ungrouped into groups that can be used for scheduling + for (var i = 0; i < _ungrouped.Count; i++) + { + MediaItem ungrouped = _ungrouped[i]; + copy[minItems + i] = new GroupedMediaItem(ungrouped, null); + } + + // perform shuffle + int n = copy.Length; + while (n > 1) + { + n--; + int k = random.Next(n + 1); + (copy[k], copy[n]) = (copy[n], copy[k]); + } + + // flatten + return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount); + } + + private static IList Shuffle(IEnumerable mediaItems, CloneableRandom random) + { + MediaItem[] copy = mediaItems.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; + } +} diff --git a/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs b/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs new file mode 100644 index 000000000..8bb176ba2 --- /dev/null +++ b/ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs @@ -0,0 +1,23 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Scheduling; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Infrastructure.Scheduling; + +public class MultiEpisodeShuffleCollectionEnumeratorFactory + : IMultiEpisodeShuffleCollectionEnumeratorFactory +{ + private readonly ILogger _logger; + + public MultiEpisodeShuffleCollectionEnumeratorFactory( + ILogger logger) + { + _logger = logger; + } + + public IMediaCollectionEnumerator Create( + string luaTemplatePath, + IList mediaItems, + CollectionEnumeratorState state) => + new MultiEpisodeShuffleCollectionEnumerator(mediaItems, state, luaTemplatePath, _logger); +} diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index bfbedff46..492b0a995 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -98,6 +98,7 @@ + diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor index 2874baed9..4b039f54c 100644 --- a/ErsatzTV/Pages/ScheduleItemsEditor.razor +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -195,6 +195,12 @@ Shuffle Shuffle In Order break; + case ProgramScheduleItemCollectionType.TelevisionShow: + Chronological + Random + Shuffle + Multi-Episode Shuffle + break; default: Chronological Random diff --git a/ErsatzTV/Resources/Scripts/_threePartEpisodes.lua b/ErsatzTV/Resources/Scripts/_threePartEpisodes.lua new file mode 100644 index 000000000..bd380e381 --- /dev/null +++ b/ErsatzTV/Resources/Scripts/_threePartEpisodes.lua @@ -0,0 +1,13 @@ +-- the number of parts that each un-split file typically contains +numParts = 3 + +-- return the part number for the given season number and episode number +function partNumberForEpisode(seasonNumber, episodeNumber) + local mod = episodeNumber % 3 + if mod == 0 + then + return 3 + else + return mod + end +end diff --git a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs index ca2bbb1d0..24ad3ac87 100644 --- a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs +++ b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs @@ -28,6 +28,12 @@ public class ResourceExtractorService : IHostedService "_default.ass.sbntxt", FileSystemLayout.MusicVideoCreditsTemplatesFolder, cancellationToken); + + await ExtractScriptResource( + assembly, + "_threePartEpisodes.lua", + FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, + cancellationToken); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; @@ -71,4 +77,18 @@ public class ResourceExtractorService : IHostedService await resource.CopyToAsync(fs, cancellationToken); } } + + private static async Task ExtractScriptResource( + Assembly assembly, + string name, + string targetFolder, + CancellationToken cancellationToken) + { + await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.Scripts.{name}"); + if (resource != null) + { + await using FileStream fs = File.Create(Path.Combine(targetFolder, name)); + await resource.CopyToAsync(fs, cancellationToken); + } + } } diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 5f7a617e2..25959b96f 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -48,6 +48,7 @@ using ErsatzTV.Infrastructure.Jellyfin; using ErsatzTV.Infrastructure.Locking; using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Infrastructure.Runtime; +using ErsatzTV.Infrastructure.Scheduling; using ErsatzTV.Infrastructure.Search; using ErsatzTV.Infrastructure.Trakt; using ErsatzTV.Serialization; @@ -199,6 +200,16 @@ public class Startup Directory.CreateDirectory(FileSystemLayout.MusicVideoCreditsTemplatesFolder); } + if (!Directory.Exists(FileSystemLayout.ScriptsFolder)) + { + Directory.CreateDirectory(FileSystemLayout.ScriptsFolder); + } + + if (!Directory.Exists(FileSystemLayout.MultiEpisodeShuffleTemplatesFolder)) + { + Directory.CreateDirectory(FileSystemLayout.MultiEpisodeShuffleTemplatesFolder); + } + Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath); // until we add a setting for a file-specific scheme://host:port to access @@ -410,6 +421,8 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();