Browse Source

rework pad and duration filler (#1304)

pull/1305/head
Jason Dove 2 years ago committed by GitHub
parent
commit
e8cbcc935f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      CHANGELOG.md
  2. 188
      ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs
  3. 92
      ErsatzTV.Core.Tests/Scheduling/ShuffledMediaCollectionEnumeratorTests.cs
  4. 1
      ErsatzTV.Core/Domain/CollectionEnumeratorState.cs
  5. 3
      ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs
  6. 10
      ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
  7. 16
      ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
  8. 175
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
  9. 42
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
  10. 44
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs
  11. 9
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs
  12. 10
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs
  13. 10
      ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs
  14. 10
      ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs
  15. 9
      ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs
  16. 35
      ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
  17. 45
      ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

5
CHANGELOG.md

@ -16,6 +16,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed ### Fixed
- Skip checking for subtitles to extract when subtitles are not enabled on a channel/schedule item - Skip checking for subtitles to extract when subtitles are not enabled on a channel/schedule item
### Changed
- For `Pad` and `Duration` filler - prioritize filling the configured pad/duration
- This will skip filler that is too long in an attempt to avoid unscheduled time
- You may see the same filler more often, which means you may want to add more filler to your library so ETV has more options
## [0.7.9-beta] - 2023-06-10 ## [0.7.9-beta] - 2023-06-10
### Added ### Added
- Synchronize actor metadata from Jellyfin and Emby television libraries - Synchronize actor metadata from Jellyfin and Emby television libraries

188
ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs

@ -23,189 +23,6 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
private CancellationToken _cancellationToken; private CancellationToken _cancellationToken;
private PlayoutModeSchedulerBase<ProgramScheduleItem> _scheduler; private PlayoutModeSchedulerBase<ProgramScheduleItem> _scheduler;
[TestFixture]
public class CalculateEndTimeWithFiller : PlayoutModeSchedulerBaseTests
{
[Test]
public void Should_Not_Touch_Enumerator()
{
var collection = new Collection
{
Id = 1,
Name = "Filler Items",
MediaItems = new List<MediaItem>()
};
for (var i = 0; i < 5; i++)
{
collection.MediaItems.Add(TestMovie(i + 1, TimeSpan.FromHours(i + 1), new DateTime(2020, 2, i + 1)));
}
var fillerPreset = new FillerPreset
{
FillerKind = FillerKind.PreRoll,
FillerMode = FillerMode.Count,
Count = 3,
Collection = collection,
CollectionId = collection.Id
};
var enumerator = new ChronologicalMediaCollectionEnumerator(
collection.MediaItems,
new CollectionEnumeratorState { Index = 0, Seed = 1 });
DateTimeOffset result = PlayoutModeSchedulerBase<ProgramScheduleItem>
.CalculateEndTimeWithFiller(
new Dictionary<CollectionKey, IMediaCollectionEnumerator>
{
{ CollectionKey.ForFillerPreset(fillerPreset), enumerator }
},
new ProgramScheduleItemOne
{
PreRollFiller = fillerPreset
},
new DateTimeOffset(2020, 2, 1, 12, 0, 0, TimeSpan.FromHours(-5)),
new TimeSpan(0, 12, 30),
new List<MediaChapter>());
result.Should().Be(new DateTimeOffset(2020, 2, 1, 18, 12, 30, TimeSpan.FromHours(-5)));
enumerator.State.Index.Should().Be(0);
enumerator.State.Seed.Should().Be(1);
}
[Test]
public void Should_Pad_To_15_Minutes_15()
{
DateTimeOffset result = PlayoutModeSchedulerBase<ProgramScheduleItem>
.CalculateEndTimeWithFiller(
new Dictionary<CollectionKey, IMediaCollectionEnumerator>(),
new ProgramScheduleItemOne
{
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 15
}
},
new DateTimeOffset(2020, 2, 1, 12, 0, 0, TimeSpan.FromHours(-5)),
new TimeSpan(0, 12, 30),
new List<MediaChapter>());
result.Should().Be(new DateTimeOffset(2020, 2, 1, 12, 15, 0, TimeSpan.FromHours(-5)));
}
[Test]
public void Should_Pad_To_15_Minutes_30()
{
DateTimeOffset result = PlayoutModeSchedulerBase<ProgramScheduleItem>
.CalculateEndTimeWithFiller(
new Dictionary<CollectionKey, IMediaCollectionEnumerator>(),
new ProgramScheduleItemOne
{
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 15
}
},
new DateTimeOffset(2020, 2, 1, 12, 16, 0, TimeSpan.FromHours(-5)),
new TimeSpan(0, 12, 30),
new List<MediaChapter>());
result.Should().Be(new DateTimeOffset(2020, 2, 1, 12, 30, 0, TimeSpan.FromHours(-5)));
}
[Test]
public void Should_Pad_To_15_Minutes_45()
{
DateTimeOffset result = PlayoutModeSchedulerBase<ProgramScheduleItem>
.CalculateEndTimeWithFiller(
new Dictionary<CollectionKey, IMediaCollectionEnumerator>(),
new ProgramScheduleItemOne
{
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 15
}
},
new DateTimeOffset(2020, 2, 1, 12, 30, 0, TimeSpan.FromHours(-5)),
new TimeSpan(0, 12, 30),
new List<MediaChapter>());
result.Should().Be(new DateTimeOffset(2020, 2, 1, 12, 45, 0, TimeSpan.FromHours(-5)));
}
[Test]
public void Should_Pad_To_15_Minutes_00()
{
DateTimeOffset result = PlayoutModeSchedulerBase<ProgramScheduleItem>
.CalculateEndTimeWithFiller(
new Dictionary<CollectionKey, IMediaCollectionEnumerator>(),
new ProgramScheduleItemOne
{
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 15
}
},
new DateTimeOffset(2020, 2, 1, 12, 46, 0, TimeSpan.FromHours(-5)),
new TimeSpan(0, 12, 30),
new List<MediaChapter>());
result.Should().Be(new DateTimeOffset(2020, 2, 1, 13, 0, 0, TimeSpan.FromHours(-5)));
}
[Test]
public void Should_Pad_To_30_Minutes_30()
{
DateTimeOffset result = PlayoutModeSchedulerBase<ProgramScheduleItem>
.CalculateEndTimeWithFiller(
new Dictionary<CollectionKey, IMediaCollectionEnumerator>(),
new ProgramScheduleItemOne
{
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 30
}
},
new DateTimeOffset(2020, 2, 1, 12, 0, 0, TimeSpan.FromHours(-5)),
new TimeSpan(0, 12, 30),
new List<MediaChapter>());
result.Should().Be(new DateTimeOffset(2020, 2, 1, 12, 30, 0, TimeSpan.FromHours(-5)));
}
[Test]
public void Should_Pad_To_30_Minutes_00()
{
DateTimeOffset result = PlayoutModeSchedulerBase<ProgramScheduleItem>
.CalculateEndTimeWithFiller(
new Dictionary<CollectionKey, IMediaCollectionEnumerator>(),
new ProgramScheduleItemOne
{
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 30
}
},
new DateTimeOffset(2020, 2, 1, 12, 20, 0, TimeSpan.FromHours(-5)),
new TimeSpan(0, 12, 30),
new List<MediaChapter>());
result.Should().Be(new DateTimeOffset(2020, 2, 1, 13, 0, 0, TimeSpan.FromHours(-5)));
}
}
[TestFixture] [TestFixture]
public class AddFiller : PlayoutModeSchedulerBaseTests public class AddFiller : PlayoutModeSchedulerBaseTests
{ {
@ -241,6 +58,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
scheduleItem, scheduleItem,
new PlayoutItem(), new PlayoutItem(),
new List<MediaChapter>(), new List<MediaChapter>(),
log: true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(1); playoutItems.Count.Should().Be(1);
@ -293,6 +111,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
scheduleItem, scheduleItem,
new PlayoutItem(), new PlayoutItem(),
new List<MediaChapter> { new() }, new List<MediaChapter> { new() },
log: true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(1); playoutItems.Count.Should().Be(1);
@ -359,6 +178,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) }, new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) } new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
}, },
log: true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(3); playoutItems.Count.Should().Be(3);
@ -450,6 +270,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) }, new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) } new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
}, },
log: true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(5); playoutItems.Count.Should().Be(5);
@ -555,6 +376,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) }, new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) } new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
}, },
log: true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(5); playoutItems.Count.Should().Be(5);

92
ErsatzTV.Core.Tests/Scheduling/ShuffledMediaCollectionEnumeratorTests.cs

@ -1,92 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using FluentAssertions;
using LanguageExt.UnsafeValueAccess;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class ShuffledMediaCollectionEnumeratorTests
{
[SetUp]
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
private readonly List<GroupedMediaItem> _mediaItems = new()
{
new GroupedMediaItem(new MediaItem { Id = 1 }, new List<MediaItem>()),
new GroupedMediaItem(new MediaItem { Id = 2 }, new List<MediaItem>()),
new GroupedMediaItem(new MediaItem { Id = 3 }, new List<MediaItem>())
};
private CancellationToken _cancellationToken;
[Test]
public void Peek_Zero_Should_Match_Current()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state, _cancellationToken);
Option<MediaItem> peek = enumerator.Peek(0);
Option<MediaItem> current = enumerator.Current;
peek.IsSome.Should().BeTrue();
current.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(1);
current.ValueUnsafe().Id.Should().Be(1);
}
[Test]
public void Peek_One_Should_Match_Next()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state, _cancellationToken);
Option<MediaItem> peek = enumerator.Peek(1);
enumerator.MoveNext();
Option<MediaItem> next = enumerator.Current;
peek.IsSome.Should().BeTrue();
next.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(2);
next.ValueUnsafe().Id.Should().Be(2);
}
[Test]
public void Peek_Two_Should_Match_NextNext()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state, _cancellationToken);
Option<MediaItem> peek = enumerator.Peek(2);
enumerator.MoveNext();
enumerator.MoveNext();
Option<MediaItem> next = enumerator.Current;
peek.IsSome.Should().BeTrue();
next.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(3);
next.ValueUnsafe().Id.Should().Be(3);
}
[Test]
public void Peek_Three_Should_Match_NextNextNext()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state, _cancellationToken);
Option<MediaItem> peek = enumerator.Peek(3);
enumerator.MoveNext();
enumerator.MoveNext();
enumerator.MoveNext();
Option<MediaItem> next = enumerator.Current;
peek.IsSome.Should().BeTrue();
next.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(2);
next.ValueUnsafe().Id.Should().Be(2);
}
}

1
ErsatzTV.Core/Domain/CollectionEnumeratorState.cs

@ -4,4 +4,5 @@ public class CollectionEnumeratorState
{ {
public int Seed { get; set; } public int Seed { get; set; }
public int Index { get; set; } public int Index { get; set; }
public CollectionEnumeratorState Clone() => new CollectionEnumeratorState { Seed = Seed, Index = Index };
} }

3
ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs

@ -4,9 +4,10 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IMediaCollectionEnumerator public interface IMediaCollectionEnumerator
{ {
IMediaCollectionEnumerator Clone(CollectionEnumeratorState state, CancellationToken cancellationToken);
CollectionEnumeratorState State { get; } CollectionEnumeratorState State { get; }
Option<MediaItem> Current { get; } Option<MediaItem> Current { get; }
void MoveNext(); void MoveNext();
Option<MediaItem> Peek(int offset); int Count { get; }
Option<TimeSpan> MinimumDuration { get; } Option<TimeSpan> MinimumDuration { get; }
} }

10
ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs

@ -31,14 +31,18 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
} }
} }
public IMediaCollectionEnumerator Clone(CollectionEnumeratorState state, CancellationToken cancellationToken)
{
return new ChronologicalMediaCollectionEnumerator(_sortedMediaItems, state);
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None; public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count; public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
public Option<MediaItem> Peek(int offset) =>
_sortedMediaItems.Any() ? _sortedMediaItems[(State.Index + offset) % _sortedMediaItems.Count] : None;
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _sortedMediaItems.Count;
} }

16
ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs

@ -6,6 +6,9 @@ namespace ErsatzTV.Core.Scheduling;
public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly Collection _collection;
private readonly IList<MediaItem> _mediaItems;
private readonly IList<MediaItem> _sortedMediaItems; private readonly IList<MediaItem> _sortedMediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration; private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
@ -14,6 +17,9 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
IList<MediaItem> mediaItems, IList<MediaItem> mediaItems,
CollectionEnumeratorState state) CollectionEnumeratorState state)
{ {
_collection = collection;
_mediaItems = mediaItems;
// TODO: this will break if we allow shows and seasons // TODO: this will break if we allow shows and seasons
_sortedMediaItems = collection.CollectionItems _sortedMediaItems = collection.CollectionItems
.OrderBy(ci => ci.CustomIndex) .OrderBy(ci => ci.CustomIndex)
@ -29,14 +35,18 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
} }
} }
public IMediaCollectionEnumerator Clone(CollectionEnumeratorState state, CancellationToken cancellationToken)
{
return new CustomOrderCollectionEnumerator(_collection, _mediaItems, state);
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None; public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count; public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
public Option<MediaItem> Peek(int offset) =>
throw new NotSupportedException();
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _sortedMediaItems.Count;
} }

175
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -4,6 +4,8 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt.UnsafeValueAccess; using LanguageExt.UnsafeValueAccess;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Serilog;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace ErsatzTV.Core.Scheduling; namespace ErsatzTV.Core.Scheduling;
@ -195,142 +197,13 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
PlayoutBuilder.DisplayTitle(mediaItem), PlayoutBuilder.DisplayTitle(mediaItem),
startTime); startTime);
internal static DateTimeOffset CalculateEndTimeWithFiller(
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
ProgramScheduleItem scheduleItem,
DateTimeOffset itemStartTime,
TimeSpan itemDuration,
List<MediaChapter> chapters)
{
var allFiller = Optional(scheduleItem.PreRollFiller)
.Append(Optional(scheduleItem.MidRollFiller))
.Append(Optional(scheduleItem.PostRollFiller))
.ToList();
// multiple pad-to-nearest-minute values are invalid; use no filler
if (allFiller.Count(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue) > 1)
{
return itemStartTime + itemDuration;
}
TimeSpan totalDuration = itemDuration;
foreach (FillerPreset filler in allFiller)
{
switch (filler.FillerKind, filler.FillerMode)
{
case (FillerKind.MidRoll, FillerMode.Duration) when filler.Duration.HasValue:
IMediaCollectionEnumerator mrde = enumerators[CollectionKey.ForFillerPreset(filler)];
var mrdePeekOffset = 0;
for (var i = 0; i < chapters.Count - 1; i++)
{
TimeSpan midRollDuration = filler.Duration.Value;
while (mrde.Peek(mrdePeekOffset))
{
foreach (MediaItem mediaItem in mrde.Peek(mrdePeekOffset))
{
TimeSpan currentDuration = DurationForMediaItem(mediaItem);
midRollDuration -= currentDuration;
if (midRollDuration >= TimeSpan.Zero)
{
totalDuration += currentDuration;
mrdePeekOffset++;
}
}
if (midRollDuration < TimeSpan.Zero)
{
break;
}
}
}
break;
case (FillerKind.MidRoll, FillerMode.Count) when filler.Count.HasValue:
IMediaCollectionEnumerator mrce = enumerators[CollectionKey.ForFillerPreset(filler)];
var mrcePeekOffset = 0;
for (var i = 0; i < chapters.Count - 1; i++)
{
for (var j = 0; j < filler.Count.Value; j++)
{
foreach (MediaItem mediaItem in mrce.Peek(mrcePeekOffset))
{
totalDuration += DurationForMediaItem(mediaItem);
mrcePeekOffset++;
}
}
}
break;
case (_, FillerMode.Duration) when filler.Duration.HasValue:
IMediaCollectionEnumerator e1 = enumerators[CollectionKey.ForFillerPreset(filler)];
var peekOffset1 = 0;
TimeSpan duration = filler.Duration.Value;
while (e1.Peek(peekOffset1).IsSome)
{
foreach (MediaItem mediaItem in e1.Peek(peekOffset1))
{
TimeSpan currentDuration = DurationForMediaItem(mediaItem);
duration -= currentDuration;
if (duration >= TimeSpan.Zero)
{
totalDuration += currentDuration;
peekOffset1++;
}
}
if (duration < TimeSpan.Zero)
{
break;
}
}
break;
case (_, FillerMode.Count) when filler.Count.HasValue:
IMediaCollectionEnumerator e2 = enumerators[CollectionKey.ForFillerPreset(filler)];
var peekOffset2 = 0;
for (var i = 0; i < filler.Count.Value; i++)
{
foreach (MediaItem mediaItem in e2.Peek(peekOffset2))
{
totalDuration += DurationForMediaItem(mediaItem);
peekOffset2++;
}
}
break;
}
}
foreach (FillerPreset padFiller in Optional(
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
{
int currentMinute = (itemStartTime + totalDuration).Minute;
// ReSharper disable once PossibleInvalidOperationException
int targetMinute = (currentMinute + padFiller.PadToNearestMinute.Value - 1) /
padFiller.PadToNearestMinute.Value * padFiller.PadToNearestMinute.Value;
DateTimeOffset targetTime = itemStartTime + totalDuration - TimeSpan.FromMinutes(currentMinute) +
TimeSpan.FromMinutes(targetMinute);
return new DateTimeOffset(
targetTime.Year,
targetTime.Month,
targetTime.Day,
targetTime.Hour,
targetTime.Minute,
0,
targetTime.Offset);
}
return itemStartTime + totalDuration;
}
internal List<PlayoutItem> AddFiller( internal List<PlayoutItem> AddFiller(
PlayoutBuilderState playoutBuilderState, PlayoutBuilderState playoutBuilderState,
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators, Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
ProgramScheduleItem scheduleItem, ProgramScheduleItem scheduleItem,
PlayoutItem playoutItem, PlayoutItem playoutItem,
List<MediaChapter> chapters, List<MediaChapter> chapters,
bool log,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = new List<PlayoutItem>(); var result = new List<PlayoutItem>();
@ -367,6 +240,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
filler.Duration.Value, filler.Duration.Value,
FillerKind.PreRoll, FillerKind.PreRoll,
filler.AllowWatermarks, filler.AllowWatermarks,
log,
cancellationToken)); cancellationToken));
break; break;
case FillerMode.Count when filler.Count.HasValue: case FillerMode.Count when filler.Count.HasValue:
@ -408,6 +282,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
filler.Duration.Value, filler.Duration.Value,
FillerKind.MidRoll, FillerKind.MidRoll,
filler.AllowWatermarks, filler.AllowWatermarks,
log,
cancellationToken)); cancellationToken));
} }
} }
@ -450,6 +325,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
filler.Duration.Value, filler.Duration.Value,
FillerKind.PostRoll, FillerKind.PostRoll,
filler.AllowWatermarks, filler.AllowWatermarks,
log,
cancellationToken)); cancellationToken));
break; break;
case FillerMode.Count when filler.Count.HasValue: case FillerMode.Count when filler.Count.HasValue:
@ -518,6 +394,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
remainingToFill, remainingToFill,
FillerKind.PreRoll, FillerKind.PreRoll,
padFiller.AllowWatermarks, padFiller.AllowWatermarks,
log,
cancellationToken)); cancellationToken));
totalDuration = totalDuration =
TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds)); TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
@ -544,6 +421,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
remainingToFill, remainingToFill,
FillerKind.MidRoll, FillerKind.MidRoll,
padFiller.AllowWatermarks, padFiller.AllowWatermarks,
log,
cancellationToken)); cancellationToken));
TimeSpan average = effectiveChapters.Count <= 1 TimeSpan average = effectiveChapters.Count <= 1
? remainingToFill ? remainingToFill
@ -607,6 +485,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
remainingToFill, remainingToFill,
FillerKind.PostRoll, FillerKind.PostRoll,
padFiller.AllowWatermarks, padFiller.AllowWatermarks,
log,
cancellationToken)); cancellationToken));
totalDuration = totalDuration =
TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds)); TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
@ -682,13 +561,15 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
TimeSpan duration, TimeSpan duration,
FillerKind fillerKind, FillerKind fillerKind,
bool allowWatermarks, bool allowWatermarks,
bool log,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = new List<PlayoutItem>(); var result = new List<PlayoutItem>();
TimeSpan remainingToFill = duration; TimeSpan remainingToFill = duration;
var skipped = false; var discardToFillAttempts = 0;
while (enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero) while (enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero &&
remainingToFill >= enumerator.MinimumDuration)
{ {
foreach (MediaItem mediaItem in enumerator.Current) foreach (MediaItem mediaItem in enumerator.Current)
{ {
@ -712,40 +593,30 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
result.Add(playoutItem); result.Add(playoutItem);
enumerator.MoveNext(); enumerator.MoveNext();
} }
else if (skipped)
{
// set to zero so it breaks out of the while loop
remainingToFill = TimeSpan.Zero;
}
else else
{ {
if (itemDuration >= duration * 1.5) discardToFillAttempts++;
if (discardToFillAttempts >= enumerator.Count)
{ {
_logger.LogWarning( // set to zero so it breaks out of the while loop
"Filler item is too long {FillerDuration} to fill {GapDuration}; skipping to next filler item", remainingToFill = TimeSpan.Zero;
itemDuration,
duration);
skipped = true;
enumerator.MoveNext();
} }
else else
{ {
if (itemDuration > duration) if (log)
{ {
_logger.LogWarning( _logger.LogDebug(
"Filler item is too long {FillerDuration} to fill {GapDuration}; aborting filler block", "Filler item is too long {FillerDuration:g} to fill {GapDuration:g}; skipping to next filler item",
itemDuration, itemDuration,
duration); remainingToFill);
} }
// set to zero so it breaks out of the while loop enumerator.MoveNext();
remainingToFill = TimeSpan.Zero;
} }
} }
} }
} }
return result; return result;
} }

42
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs

@ -121,25 +121,41 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime); durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc); DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc);
DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller(
collectionEnumerators, var enumeratorClones = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{
IMediaCollectionEnumerator clone = enumerator.Clone(enumerator.State.Clone(), cancellationToken);
enumeratorClones.Add(key, clone);
}
List<PlayoutItem> maybePlayoutItems = AddFiller(
nextState,
enumeratorClones,
scheduleItem, scheduleItem,
itemStartTime, playoutItem,
itemDuration, itemChapters,
itemChapters); log: false,
cancellationToken);
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
willFinishInTime = itemStartTime > durationFinish || willFinishInTime = itemStartTime > durationFinish ||
itemEndTimeWithFiller <= durationFinish; itemEndTimeWithFiller <= durationFinish;
if (willFinishInTime) if (willFinishInTime)
{ {
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime); // LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
playoutItems.AddRange( playoutItems.AddRange(maybePlayoutItems);
AddFiller(
nextState, // update original enumerators
collectionEnumerators, foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
scheduleItem, {
playoutItem, IMediaCollectionEnumerator clone = enumeratorClones[key];
itemChapters, while (enumerator.State.Seed != clone.State.Seed || enumerator.State.Index != clone.State.Index)
cancellationToken)); {
enumerator.MoveNext();
}
}
nextState = nextState with nextState = nextState with
{ {

44
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs

@ -74,12 +74,23 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
? GetStartTimeAfter(nextState with { InFlood = false }, peekScheduleItem) ? GetStartTimeAfter(nextState with { InFlood = false }, peekScheduleItem)
: DateTimeOffset.MaxValue; : DateTimeOffset.MaxValue;
DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller( var enumeratorClones = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
collectionEnumerators, foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{
IMediaCollectionEnumerator clone = enumerator.Clone(enumerator.State.Clone(), cancellationToken);
enumeratorClones.Add(key, clone);
}
List<PlayoutItem> maybePlayoutItems = AddFiller(
nextState,
enumeratorClones,
scheduleItem, scheduleItem,
itemStartTime, playoutItem,
itemDuration, itemChapters,
itemChapters); log: false,
cancellationToken);
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
// if the next schedule item is supposed to start during this item, // if the next schedule item is supposed to start during this item,
// don't schedule this item and just move on // don't schedule this item and just move on
@ -88,27 +99,16 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
if (willFinishInTime) if (willFinishInTime)
{ {
playoutItems.AddRange( playoutItems.AddRange(maybePlayoutItems);
AddFiller(
nextState,
collectionEnumerators,
scheduleItem,
playoutItem,
itemChapters,
cancellationToken));
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime); // LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
if (playoutItems.Count > 0) // update original enumerators
foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{ {
DateTimeOffset actualEndTime = playoutItems.Max(p => p.FinishOffset); IMediaCollectionEnumerator clone = enumeratorClones[key];
if (Math.Abs((itemEndTimeWithFiller - actualEndTime).TotalSeconds) > 1) while (enumerator.State.Seed != clone.State.Seed || enumerator.State.Index != clone.State.Index)
{ {
_logger.LogWarning( enumerator.MoveNext();
"Filler prediction failure: predicted {PredictedDuration} doesn't match actual {ActualDuration}",
itemEndTimeWithFiller,
actualEndTime);
// _logger.LogWarning("Playout items: {@PlayoutItems}", playoutItems);
} }
} }

9
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs

@ -77,12 +77,6 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
}; };
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime); // LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller(
collectionEnumerators,
scheduleItem,
itemStartTime,
itemDuration,
itemChapters);
playoutItems.AddRange( playoutItems.AddRange(
AddFiller( AddFiller(
@ -91,11 +85,12 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
scheduleItem, scheduleItem,
playoutItem, playoutItem,
itemChapters, itemChapters,
log: true,
cancellationToken)); cancellationToken));
nextState = nextState with nextState = nextState with
{ {
CurrentTime = itemEndTimeWithFiller, CurrentTime = playoutItems.Max(pi => pi.FinishOffset),
MultipleRemaining = nextState.MultipleRemaining.Map(i => i - 1), MultipleRemaining = nextState.MultipleRemaining.Map(i => i - 1),
// only bump guide group if we don't have a custom title // only bump guide group if we don't have a custom title

10
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs

@ -53,24 +53,18 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
SubtitleMode = scheduleItem.SubtitleMode SubtitleMode = scheduleItem.SubtitleMode
}; };
DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller(
collectionEnumerators,
scheduleItem,
itemStartTime,
itemDuration,
itemChapters);
List<PlayoutItem> playoutItems = AddFiller( List<PlayoutItem> playoutItems = AddFiller(
playoutBuilderState, playoutBuilderState,
collectionEnumerators, collectionEnumerators,
scheduleItem, scheduleItem,
playoutItem, playoutItem,
itemChapters, itemChapters,
log: true,
cancellationToken); cancellationToken);
PlayoutBuilderState nextState = playoutBuilderState with PlayoutBuilderState nextState = playoutBuilderState with
{ {
CurrentTime = itemEndTimeWithFiller CurrentTime = playoutItems.Max(pi => pi.FinishOffset)
}; };
nextState.ScheduleItemsEnumerator.MoveNext(); nextState.ScheduleItemsEnumerator.MoveNext();

10
ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs

@ -26,6 +26,11 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
} }
} }
public IMediaCollectionEnumerator Clone(CollectionEnumeratorState state, CancellationToken cancellationToken)
{
return new RandomizedMediaCollectionEnumerator(_mediaItems, state);
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _mediaItems.Any() ? _mediaItems[_index] : None; public Option<MediaItem> Current => _mediaItems.Any() ? _mediaItems[_index] : None;
@ -36,8 +41,7 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
State.Index++; State.Index++;
} }
public Option<MediaItem> Peek(int offset) =>
throw new NotSupportedException();
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _mediaItems.Count;
} }

10
ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs

@ -31,14 +31,18 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
} }
} }
public IMediaCollectionEnumerator Clone(CollectionEnumeratorState state, CancellationToken cancellationToken)
{
return new SeasonEpisodeMediaCollectionEnumerator(_sortedMediaItems, state);
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None; public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count; public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
public Option<MediaItem> Peek(int offset) =>
_sortedMediaItems.Any() ? _sortedMediaItems[(State.Index + offset) % _sortedMediaItems.Count] : None;
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _sortedMediaItems.Count;
} }

9
ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs

@ -42,6 +42,11 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
} }
} }
public IMediaCollectionEnumerator Clone(CollectionEnumeratorState state, CancellationToken cancellationToken)
{
return new ShuffleInOrderCollectionEnumerator(_collections, state, _randomStartPoint, cancellationToken);
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None; public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None;
@ -69,8 +74,6 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
State.Index %= _shuffled.Count; State.Index %= _shuffled.Count;
} }
public Option<MediaItem> Peek(int offset) => throw new NotSupportedException();
private IList<MediaItem> Shuffle(IList<CollectionWithItems> collections, Random random) private IList<MediaItem> Shuffle(IList<CollectionWithItems> collections, Random random)
{ {
// based on https://keyj.emphy.de/balanced-shuffle/ // based on https://keyj.emphy.de/balanced-shuffle/
@ -208,4 +211,6 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
} }

35
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -39,6 +39,11 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
} }
} }
public IMediaCollectionEnumerator Clone(CollectionEnumeratorState state, CancellationToken cancellationToken)
{
return new ShuffledMediaCollectionEnumerator(_mediaItems, state, cancellationToken);
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None; public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None;
@ -66,34 +71,6 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
State.Index %= _mediaItemCount; State.Index %= _mediaItemCount;
} }
public Option<MediaItem> Peek(int offset)
{
if (offset == 0)
{
return Current;
}
if ((State.Index + offset) % _mediaItemCount == 0)
{
IList<MediaItem> shuffled;
Option<MediaItem> tail = Current;
// clone the random
CloneableRandom randomCopy = _random.Clone();
do
{
int newSeed = randomCopy.Next();
randomCopy = new CloneableRandom(newSeed);
shuffled = Shuffle(_mediaItems, randomCopy);
} while (_mediaItems.Count > 1 && shuffled[0] == tail);
return shuffled.Any() ? shuffled[0] : None;
}
return _shuffled.Any() ? _shuffled[(State.Index + offset) % _mediaItemCount] : None;
}
private IList<MediaItem> Shuffle(IEnumerable<GroupedMediaItem> list, CloneableRandom random) private IList<MediaItem> Shuffle(IEnumerable<GroupedMediaItem> list, CloneableRandom random)
{ {
GroupedMediaItem[] copy = list.ToArray(); GroupedMediaItem[] copy = list.ToArray();
@ -110,4 +87,6 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
} }

45
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

@ -10,6 +10,8 @@ namespace ErsatzTV.Infrastructure.Scheduling;
public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerator public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly CancellationToken _cancellationToken; private readonly CancellationToken _cancellationToken;
private readonly IScriptEngine _scriptEngine;
private readonly string _scriptFile;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly int _mediaItemCount; private readonly int _mediaItemCount;
private readonly Dictionary<int, List<MediaItem>> _mediaItemGroups; private readonly Dictionary<int, List<MediaItem>> _mediaItemGroups;
@ -26,6 +28,8 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
ILogger logger, ILogger logger,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
_scriptEngine = scriptEngine;
_scriptFile = scriptFile;
_logger = logger; _logger = logger;
_cancellationToken = cancellationToken; _cancellationToken = cancellationToken;
@ -83,6 +87,17 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
} }
} }
public IMediaCollectionEnumerator Clone(CollectionEnumeratorState state, CancellationToken cancellationToken)
{
return new MultiEpisodeShuffleCollectionEnumerator(
_ungrouped,
state,
_scriptEngine,
_scriptFile,
_logger,
cancellationToken);
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None; public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None;
@ -110,34 +125,6 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
State.Index %= _mediaItemCount; State.Index %= _mediaItemCount;
} }
public Option<MediaItem> Peek(int offset)
{
if (offset == 0)
{
return Current;
}
if ((State.Index + offset) % _mediaItemCount == 0)
{
IList<MediaItem> shuffled;
Option<MediaItem> 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<MediaItem> Shuffle(CloneableRandom random) private IList<MediaItem> Shuffle(CloneableRandom random)
{ {
int maxGroupNumber = _mediaItemGroups.Max(a => a.Key); int maxGroupNumber = _mediaItemGroups.Max(a => a.Key);
@ -202,4 +189,6 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
} }

Loading…
Cancel
Save