From ab559787329c4658f409a2a2abd57d13a9bde502 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Thu, 13 Apr 2023 11:06:48 -0500 Subject: [PATCH] add season, episode playback order for shows (#1243) --- CHANGELOG.md | 5 ++ .../ProgramScheduleItemCommandBase.cs | 1 + .../Scheduling/ChronologicalContentTests.cs | 3 +- .../Scheduling/SeasonEpisodeContentTests.cs | 88 +++++++++++++++++++ ErsatzTV.Core/Domain/PlaybackOrder.cs | 3 +- .../Scheduling/MultiCollectionGroup.cs | 18 +++- ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 11 +++ .../SeasonEpisodeMediaCollectionEnumerator.cs | 38 ++++++++ .../Scheduling/SeasonEpisodeMediaComparer.cs | 54 ++++++++++++ ErsatzTV/Pages/ScheduleItemsEditor.razor | 1 + 10 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Scheduling/SeasonEpisodeContentTests.cs create mode 100644 ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs create mode 100644 ErsatzTV.Core/Scheduling/SeasonEpisodeMediaComparer.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae62896..1e0c74a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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 `Season, Episode` playback order + - This is currently *only* available when a show is added directly to a schedule + - This will ignore release date and sort exclusively by season number and then by episode number + ### Fixed - Limit `HLS Direct` streams to realtime speed - Fix `Reset Playout` button to use worker thread instead of UI thread diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs index 58be0067..7d46a7c8 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs @@ -56,6 +56,7 @@ public abstract class ProgramScheduleItemCommandBase case PlaybackOrder.Chronological: case PlaybackOrder.Random: case PlaybackOrder.MultiEpisodeShuffle: + case PlaybackOrder.SeasonEpisode: return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'"); case PlaybackOrder.Shuffle: case PlaybackOrder.ShuffleInOrder: diff --git a/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs index 767c93ae..250c9f93 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs @@ -78,7 +78,8 @@ public class ChronologicalContentTests { new() { - ReleaseDate = new DateTime(2020, 1, i) + ReleaseDate = new DateTime(2020, 1, i), + EpisodeNumber = 20 - i } } }) diff --git a/ErsatzTV.Core.Tests/Scheduling/SeasonEpisodeContentTests.cs b/ErsatzTV.Core.Tests/Scheduling/SeasonEpisodeContentTests.cs new file mode 100644 index 00000000..59d4ffe0 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/SeasonEpisodeContentTests.cs @@ -0,0 +1,88 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using FluentAssertions; +using NUnit.Framework; + +namespace ErsatzTV.Core.Tests.Scheduling; + +[TestFixture] +public class SeasonEpisodeContentTests +{ + [Test] + public void Episodes_Should_Sort_By_EpisodeNumber() + { + List contents = Episodes(10); + var state = new CollectionEnumeratorState(); + + var chronologicalContent = new SeasonEpisodeMediaCollectionEnumerator(contents, state); + + for (var i = 1; i <= 10; i++) + { + chronologicalContent.Current.IsSome.Should().BeTrue(); + chronologicalContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i); + chronologicalContent.MoveNext(); + } + } + + [Test] + public void State_Index_Should_Increment() + { + List contents = Episodes(10); + var state = new CollectionEnumeratorState(); + + var chronologicalContent = new SeasonEpisodeMediaCollectionEnumerator(contents, state); + + for (var i = 0; i < 10; i++) + { + chronologicalContent.State.Index.Should().Be(i % 10); + chronologicalContent.MoveNext(); + } + } + + [Test] + public void State_Should_Impact_Iterator_Start() + { + List contents = Episodes(10); + var state = new CollectionEnumeratorState { Index = 5 }; + + var chronologicalContent = new SeasonEpisodeMediaCollectionEnumerator(contents, state); + + for (var i = 6; i <= 10; i++) + { + chronologicalContent.Current.IsSome.Should().BeTrue(); + chronologicalContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i); + chronologicalContent.State.Index.Should().Be(i - 1); + chronologicalContent.MoveNext(); + } + } + + [Test] + [Timeout(1000)] + public void State_Should_Reset_When_Invalid() + { + List contents = Episodes(10); + var state = new CollectionEnumeratorState { Index = 10 }; + + var chronologicalContent = new SeasonEpisodeMediaCollectionEnumerator(contents, state); + + chronologicalContent.State.Index.Should().Be(0); + chronologicalContent.State.Seed.Should().Be(0); + } + + private static List Episodes(int count) => + Range(1, count).Map( + i => (MediaItem)new Episode + { + Id = i, + EpisodeMetadata = new List + { + new() + { + ReleaseDate = new DateTime(2020, 1, 20 - i), + EpisodeNumber = i + } + } + }) + .Reverse() + .ToList(); +} diff --git a/ErsatzTV.Core/Domain/PlaybackOrder.cs b/ErsatzTV.Core/Domain/PlaybackOrder.cs index 67b215d7..7568e592 100644 --- a/ErsatzTV.Core/Domain/PlaybackOrder.cs +++ b/ErsatzTV.Core/Domain/PlaybackOrder.cs @@ -6,5 +6,6 @@ public enum PlaybackOrder Random = 2, Shuffle = 3, ShuffleInOrder = 4, - MultiEpisodeShuffle = 5 + MultiEpisodeShuffle = 5, + SeasonEpisode = 6 } diff --git a/ErsatzTV.Core/Scheduling/MultiCollectionGroup.cs b/ErsatzTV.Core/Scheduling/MultiCollectionGroup.cs index 55e55f70..8bb56747 100644 --- a/ErsatzTV.Core/Scheduling/MultiCollectionGroup.cs +++ b/ErsatzTV.Core/Scheduling/MultiCollectionGroup.cs @@ -23,10 +23,20 @@ public class MultiCollectionGroup : GroupedMediaItem switch (collectionWithItems.PlaybackOrder) { case PlaybackOrder.Chronological: - var sortedItems = collectionWithItems.MediaItems.OrderBy(identity, new ChronologicalMediaComparer()) - .ToList(); - First = sortedItems.Head(); - Additional = sortedItems.Tail().ToList(); + { + var sortedItems = collectionWithItems.MediaItems.OrderBy(identity, new ChronologicalMediaComparer()) + .ToList(); + First = sortedItems.Head(); + Additional = sortedItems.Tail().ToList(); + } + break; + case PlaybackOrder.SeasonEpisode: + { + var sortedItems = collectionWithItems.MediaItems.OrderBy(identity, new SeasonEpisodeMediaComparer()) + .ToList(); + First = sortedItems.Head(); + Additional = sortedItems.Tail().ToList(); + } break; default: throw new NotSupportedException( diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 3d251553..494b4a25 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -772,6 +772,17 @@ public class PlayoutBuilder : IPlayoutBuilder } return new ChronologicalMediaCollectionEnumerator(mediaItems, state); + case PlaybackOrder.SeasonEpisode: + if (randomStartPoint) + { + state = new CollectionEnumeratorState + { + Seed = state.Seed, + Index = Random.Next(0, mediaItems.Count - 1) + }; + } + + return new SeasonEpisodeMediaCollectionEnumerator(mediaItems, state); case PlaybackOrder.Random: return new RandomizedMediaCollectionEnumerator(mediaItems, state); case PlaybackOrder.ShuffleInOrder: diff --git a/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs new file mode 100644 index 00000000..bdc679b2 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs @@ -0,0 +1,38 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Scheduling; + +namespace ErsatzTV.Core.Scheduling; + +public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnumerator +{ + private readonly IList _sortedMediaItems; + + public SeasonEpisodeMediaCollectionEnumerator( + IEnumerable mediaItems, + CollectionEnumeratorState state) + { + _sortedMediaItems = mediaItems.OrderBy(identity, new SeasonEpisodeMediaComparer()).ToList(); + + State = new CollectionEnumeratorState { Seed = state.Seed }; + + if (state.Index >= _sortedMediaItems.Count) + { + state.Index = 0; + state.Seed = 0; + } + + while (State.Index < state.Index) + { + MoveNext(); + } + } + + public CollectionEnumeratorState State { get; } + + public Option Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None; + + public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count; + + public Option Peek(int offset) => + _sortedMediaItems.Any() ? _sortedMediaItems[(State.Index + offset) % _sortedMediaItems.Count] : None; +} diff --git a/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaComparer.cs b/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaComparer.cs new file mode 100644 index 00000000..7490457b --- /dev/null +++ b/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaComparer.cs @@ -0,0 +1,54 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Scheduling; + +internal class SeasonEpisodeMediaComparer : IComparer +{ + public int Compare(MediaItem x, MediaItem y) + { + if (x == null || y == null) + { + return 0; + } + + int season1 = x switch + { + Episode e => e.Season?.SeasonNumber ?? int.MaxValue, + _ => int.MaxValue + }; + + int season2 = y switch + { + Episode e => e.Season?.SeasonNumber ?? int.MaxValue, + _ => int.MaxValue + }; + + if (season1 != season2) + { + return season1.CompareTo(season2); + } + + int episode1 = x switch + { + Episode e => e.EpisodeMetadata.HeadOrNone().Match( + em => em.EpisodeNumber, + () => int.MaxValue), + _ => int.MaxValue + }; + + int episode2 = y switch + { + Episode e => e.EpisodeMetadata.HeadOrNone().Match( + em => em.EpisodeNumber, + () => int.MaxValue), + _ => int.MaxValue + }; + + if (episode1 != episode2) + { + return episode1.CompareTo(episode2); + } + + return x.Id.CompareTo(y.Id); + } +} diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor index 83ff23cc..378da1be 100644 --- a/ErsatzTV/Pages/ScheduleItemsEditor.razor +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -200,6 +200,7 @@ break; case ProgramScheduleItemCollectionType.TelevisionShow: Chronological + Season, Episode Random Shuffle Multi-Episode Shuffle