From 6a84c564d6c2121b987830f63d87b9f8dfe6974a Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:46:00 +0000 Subject: [PATCH] add multi-episode group size (#2164) --- CHANGELOG.md | 1 + .../PlayoutModeSchedulerMultipleTests.cs | 102 ++++++++++++++++++ ErsatzTV.Core/Domain/MultipleMode.cs | 2 +- .../ChronologicalMediaCollectionEnumerator.cs | 24 +++++ .../PlayoutModeSchedulerMultiple.cs | 12 +++ ErsatzTV/Pages/ScheduleItemsEditor.razor | 12 +++ .../ProgramScheduleItemEditViewModel.cs | 31 +++++- 7 files changed, 182 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1293dc..497512fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `Count`: same behavior as before, requires a number of media items to play and will always schedule the same number - `Collection Size`: similar to count of zero before, will play all media items from the collection before continuing to the next schedule item - `Playlist Item Size`: will play all media items from the current playlist item before continuing to the next schedule item + - `Multi-Episode Group Size`: will play all media items from the current multi-part episode group, or one ungrouped media item ### Fixed - Fix QSV acceleration in docker with older Intel devices diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerMultipleTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerMultipleTests.cs index f4d58ee5..fdbb04ed 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerMultipleTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerMultipleTests.cs @@ -88,6 +88,108 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase playoutItems[1].CustomTitle.ShouldBe("CustomTitle"); } + [Test] + public void Should_Schedule_Multi_Part_Size_Correctly() + { + var season = new Season { ShowId = 1 }; + + var collectionOne = new Collection + { + Id = 1, + Name = "Episode collection", + MediaItems = + [ + new Episode + { + Id = 1, + EpisodeMetadata = [new EpisodeMetadata { Title = "Episode One (1)" }], + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromHours(1), Chapters = [] }], + Season = season + }, + new Episode + { + Id = 2, + EpisodeMetadata = [new EpisodeMetadata { Title = "Episode Two (2)" }], + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromHours(1), Chapters = [] }], + Season = season + }, + new Episode + { + Id = 3, + EpisodeMetadata = [new EpisodeMetadata { Title = "Episode Three" }], + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromHours(1), Chapters = [] }], + Season = season + } + ] + }; + + var scheduleItem = new ProgramScheduleItemMultiple + { + Id = 1, + Index = 1, + CollectionType = ProgramScheduleItemCollectionType.Collection, + Collection = collectionOne, + CollectionId = collectionOne.Id, + StartTime = TimeSpan.FromHours(1), + PlaybackOrder = PlaybackOrder.Chronological, + TailFiller = null, + FallbackFiller = null, + Count = 0, + MultipleMode = MultipleMode.MultiEpisodeGroupSize, + CustomTitle = "CustomTitle" + }; + + var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator( + new List { scheduleItem }, + new CollectionEnumeratorState()); + + var enumerator = new ChronologicalMediaCollectionEnumerator( + collectionOne.MediaItems, + new CollectionEnumeratorState()); + + var collectionItemCount = new Dictionary + { + { CollectionKey.ForScheduleItem(scheduleItem), collectionOne.MediaItems.Count } + }.ToMap(); + + PlayoutBuilderState startState = StartState(scheduleItemsEnumerator); + + var scheduler = new PlayoutModeSchedulerMultiple(collectionItemCount, Substitute.For()); + (PlayoutBuilderState playoutBuilderState, List playoutItems) = scheduler.Schedule( + startState, + CollectionEnumerators(scheduleItem, enumerator), + scheduleItem, + NextScheduleItem, + HardStop(scheduleItemsEnumerator), + _cancellationToken); + + playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3)); + playoutItems.Last().FinishOffset.ShouldBe(playoutBuilderState.CurrentTime); + + playoutBuilderState.NextGuideGroup.ShouldBe(2); // one guide group here because of custom title + playoutBuilderState.DurationFinish.IsNone.ShouldBeTrue(); + playoutBuilderState.InFlood.ShouldBeFalse(); + playoutBuilderState.MultipleRemaining.IsNone.ShouldBeTrue(); + playoutBuilderState.InDurationFiller.ShouldBeFalse(); + playoutBuilderState.ScheduleItemsEnumerator.State.Index.ShouldBe(0); + + enumerator.State.Index.ShouldBe(2); + + playoutItems.Count.ShouldBe(2); + + playoutItems[0].MediaItemId.ShouldBe(1); + playoutItems[0].StartOffset.ShouldBe(startState.CurrentTime.AddHours(1)); + playoutItems[0].GuideGroup.ShouldBe(1); + playoutItems[0].FillerKind.ShouldBe(FillerKind.None); + playoutItems[0].CustomTitle.ShouldBe("CustomTitle"); + + playoutItems[1].MediaItemId.ShouldBe(2); + playoutItems[1].StartOffset.ShouldBe(startState.CurrentTime.AddHours(2)); + playoutItems[1].GuideGroup.ShouldBe(1); + playoutItems[1].FillerKind.ShouldBe(FillerKind.None); + playoutItems[1].CustomTitle.ShouldBe("CustomTitle"); + } + [Test] public void Should_Fill_Exactly_To_Next_Schedule_Item() { diff --git a/ErsatzTV.Core/Domain/MultipleMode.cs b/ErsatzTV.Core/Domain/MultipleMode.cs index 9ff4472a..d6d53d0a 100644 --- a/ErsatzTV.Core/Domain/MultipleMode.cs +++ b/ErsatzTV.Core/Domain/MultipleMode.cs @@ -13,5 +13,5 @@ public enum MultipleMode // from one item (not a multi-episode) to however many multi-episodes are linked together // is this limited to chronological and season/episode? - MultiEpisodeSize = 3 + MultiEpisodeGroupSize = 3 } diff --git a/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs index caacffd8..9e6f1595 100644 --- a/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs +++ b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs @@ -8,6 +8,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu { private readonly Lazy> _lazyMinimumDuration; private readonly List _sortedMediaItems; + private readonly Lazy> _lazyMediaItemGroupSize; public ChronologicalMediaCollectionEnumerator( IEnumerable mediaItems, @@ -19,6 +20,8 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu _lazyMinimumDuration = new Lazy>(() => _sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone()); + _lazyMediaItemGroupSize = new Lazy>(CalculateMediaItemGroupSizes); + State = new CollectionEnumeratorState { Seed = state.Seed }; if (state.Index >= _sortedMediaItems.Count) @@ -33,6 +36,24 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu } } + private Dictionary CalculateMediaItemGroupSizes() + { + var result = new Dictionary(); + + List groupedItems = MultiPartEpisodeGrouper.GroupMediaItems(_sortedMediaItems, false); + foreach (GroupedMediaItem group in groupedItems) + { + int size = group.Additional.Count + 1; + result[group.First.Id] = size; + foreach (MediaItem additional in group.Additional) + { + result[additional.Id] = size; + } + } + + return result; + } + public void ResetState(CollectionEnumeratorState state) => // seed doesn't matter in chronological State.Index = state.Index; @@ -47,4 +68,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu public Option MinimumDuration => _lazyMinimumDuration.Value; public int Count => _sortedMediaItems.Count; + + public int GroupSizeForMediaItem(MediaItem mediaItem) => + _lazyMediaItemGroupSize.Value.GetValueOrDefault(mediaItem.Id, 1); } diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs index 614f9eb7..87606f2c 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs @@ -61,6 +61,18 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase Count + @if (_selectedItem.CollectionType is not ProgramScheduleItemCollectionType.Playlist) { Collection Size @@ -387,6 +394,11 @@ { Playlist Item Size } + + @if (_selectedItem.PlaybackOrder is PlaybackOrder.Chronological) + { + Multi-Episode Group Size + } diff --git a/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs b/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs index 1642577b..81fe9ab4 100644 --- a/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs +++ b/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs @@ -20,6 +20,7 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged private int _playoutDurationHours; private int _playoutDurationMinutes; private TimeSpan? _startTime; + private PlaybackOrder _playbackOrder; public int Id { get; set; } public int Index { get; set; } @@ -61,10 +62,17 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged MediaItem = null; SmartCollection = null; + if (_collectionType != ProgramScheduleItemCollectionType.Playlist && + MultipleMode is MultipleMode.PlaylistItemSize) + { + MultipleMode = MultipleMode.Count; + } + OnPropertyChanged(nameof(Collection)); OnPropertyChanged(nameof(MultiCollection)); OnPropertyChanged(nameof(MediaItem)); OnPropertyChanged(nameof(SmartCollection)); + OnPropertyChanged(nameof(MultiCollection)); } if (_collectionType == ProgramScheduleItemCollectionType.MultiCollection) @@ -102,7 +110,28 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged _ => string.Empty }; - public PlaybackOrder PlaybackOrder { get; set; } + public PlaybackOrder PlaybackOrder + { + get => _playbackOrder; + set + { + if (value == _playbackOrder) + { + return; + } + + _playbackOrder = value; + + if (_playbackOrder is not PlaybackOrder.Chronological && MultipleMode is MultipleMode.MultiEpisodeGroupSize) + { + MultipleMode = MultipleMode.Count; + } + + OnPropertyChanged(); + OnPropertyChanged(nameof(CanFillWithGroups)); + OnPropertyChanged(nameof(MultipleMode)); + } + } public MultipleMode MultipleMode { get; set; }