Browse Source

fix: playlist enumerator consistency fixes (#2956)

* fix: playlist enumerator consistency fixes

* cleanup
pull/2957/head
Jason Dove 6 days ago committed by GitHub
parent
commit
16b7156996
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 10
      CHANGELOG.md
  2. 19
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/AlternateScheduleAnchorTests.cs
  3. 3
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs
  4. 46
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs
  5. 175
      ErsatzTV.Core.Tests/Scheduling/MarathonEnumeratorTests.cs
  6. 67
      ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs
  7. 7
      ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs
  8. 115
      ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs
  9. 28
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  10. 7
      ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs

10
CHANGELOG.md

@ -24,6 +24,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix case where shuffled collection progress would reset early - Fix case where shuffled collection progress would reset early
- This only happened when also grouping episodes (e.g. `Keep Multi-Part Episodes Together`) - This only happened when also grouping episodes (e.g. `Keep Multi-Part Episodes Together`)
- More groups made it more likely to happen - More groups made it more likely to happen
- Fix marathons and playlists that would never pick a new order
- `Marathon Shuffle Groups` and `Shuffle Playlist Items` choose a new order every time everything has played once
- Content that can never play was still counted as waiting to play, so that never happened and the order stayed the same forever
- This was caused by specials (season 0), which are skipped by `Season, Episode` order; marathons grouped by show or season use that order unless `Marathon Shuffle Items` is enabled
- Affected playouts also became slower to build the longer they ran
- Fix shuffled playlists and marathon groups changing order when a playout is rebuilt
- Existing playlists and marathons will change order once after updating
- Fix playlists used as filler skipping content
- When something doesn't fit, ETV puts the filler back where it was and tries again, but playlists were only partly put back
- This could skip playlist content, and could cause the next playout build to resume in the wrong place
- Fix green line sometimes seen with NVIDIA and AMD/VAAPI encoding - Fix green line sometimes seen with NVIDIA and AMD/VAAPI encoding
- Both bugs were in ffmpeg, and ETV's patched ffmpeg 8.1.2 is required for the fixes - Both bugs were in ffmpeg, and ETV's patched ffmpeg 8.1.2 is required for the fixes

19
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/AlternateScheduleAnchorTests.cs

@ -11,24 +11,16 @@ using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
/// <summary> // a collection used only by an alternate schedule still gets an enumerator on days that alternate
/// A collection used only by an alternate schedule still gets an enumerator on days when that // isn't active, because media items are gathered once for the primary schedule and every alternate
/// alternate isn't active, because the collection media items are gathered once for the primary
/// schedule and every alternate. On those days it falls into the filler branch and gets a
/// shuffled enumerator, whose index domain is the grouped item count rather than the media item
/// count.
/// </summary>
[TestFixture] [TestFixture]
public class AlternateScheduleAnchorTests : PlayoutBuilderTestBase public class AlternateScheduleAnchorTests : PlayoutBuilderTestBase
{ {
private const int PrimaryCollectionId = 1; private const int PrimaryCollectionId = 1;
private const int AlternateCollectionId = 2; private const int AlternateCollectionId = 2;
/// <summary> // season/episode order over 10 episodes indexes 0-9; with a multi-part pair, the shuffled
/// The alternate's collection has 10 episodes and its schedule orders them by season/episode, // enumerator used on inactive days sees 9 groups, so index 9 is outside its domain
/// so its own enumerator's index domain is 0-9. With a multi-part pair the shuffled enumerator
/// used on inactive days groups those 10 items into 9, so index 9 is out of its domain.
/// </summary>
[TestCase(9, true, ExpectedResult = 9, TestName = "Index at the grouped count")] [TestCase(9, true, ExpectedResult = 9, TestName = "Index at the grouped count")]
[TestCase(8, true, ExpectedResult = 8, TestName = "Index below the grouped count")] [TestCase(8, true, ExpectedResult = 8, TestName = "Index below the grouped count")]
[TestCase(9, false, ExpectedResult = 9, TestName = "Index at the grouped count, nothing grouped")] [TestCase(9, false, ExpectedResult = 9, TestName = "Index at the grouped count, nothing grouped")]
@ -47,8 +39,7 @@ public class AlternateScheduleAnchorTests : PlayoutBuilderTestBase
EnumeratorState = new CollectionEnumeratorState { Seed = 1234, Index = index } EnumeratorState = new CollectionEnumeratorState { Seed = 1234, Index = index }
}); });
// a Wednesday, so the alternate (Sundays only) is not active and the alternate's // a Wednesday, so the alternate (Sundays only) is not active
// collection is not scheduled at all
DateTimeOffset now = LocalTime(new DateTime(2025, 6, 11), 20); DateTimeOffset now = LocalTime(new DateTime(2025, 6, 11), 20);
now.DayOfWeek.ShouldBe(DayOfWeek.Wednesday); now.DayOfWeek.ShouldBe(DayOfWeek.Wednesday);

3
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs

@ -468,8 +468,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
result.AddedItems.Count.ShouldBe(53); result.AddedItems.Count.ShouldBe(53);
} }
// two collections, each with a checkpoint for the two midnights the build crossed // two collections, each with a continue anchor and a checkpoint per midnight crossed
// plus a continue anchor
playout.ProgramScheduleAnchors.Count.ShouldBe(6); playout.ProgramScheduleAnchors.Count.ShouldBe(6);
playout.ProgramScheduleAnchors.Count(x => x.AnchorDate is not null).ShouldBe(4); playout.ProgramScheduleAnchors.Count(x => x.AnchorDate is not null).ShouldBe(4);
PlayoutProgramScheduleAnchor lastCheckpoint = playout.ProgramScheduleAnchors PlayoutProgramScheduleAnchor lastCheckpoint = playout.ProgramScheduleAnchors

46
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/MultiDayBuildTests.cs

@ -11,11 +11,8 @@ using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
/// <summary> // unit-test version of the "fast forward a playout" debugging endpoint that used to live in
/// Unit-test version of the "fast forward a playout" debugging endpoint that used to live in // ChannelController
/// ChannelController: run hourly continue builds across several days and assert invariants at
/// every step.
/// </summary>
[TestFixture] [TestFixture]
public class MultiDayBuildTests : PlayoutBuilderTestBase public class MultiDayBuildTests : PlayoutBuilderTestBase
{ {
@ -25,13 +22,11 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase
[Test] [Test]
public async Task Continue_Should_Keep_A_Checkpoint_For_The_Current_Day() public async Task Continue_Should_Keep_A_Checkpoint_For_The_Current_Day()
{ {
// 6 hour movies scheduled 3 at a time, so a block spans 18 hours and regularly // 6 hour movies 3 at a time, so an 18 hour block regularly crosses midnight
// crosses midnight - which is expected and intended
(PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData(
TimeSpan.FromHours(6), TimeSpan.FromHours(6),
multipleCount: "3"); multipleCount: "3");
// a playout created at 20:00, then built every hour for a week
DateTimeOffset start = LocalTime(20); DateTimeOffset start = LocalTime(20);
var missing = new List<string>(); var missing = new List<string>();
@ -48,15 +43,13 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase
result.IsRight.ShouldBeTrue($"build failed at {now:yyyy-MM-dd HH:mm}"); result.IsRight.ShouldBeTrue($"build failed at {now:yyyy-MM-dd HH:mm}");
// a build never writes a checkpoint for its own first day, and doesn't need to: // no checkpoint is written for a build's own first day, and none is needed: there is no
// there is no earlier state to restore, so refreshing starts from index 0 and // earlier state, so refreshing from index 0 reproduces the same schedule
// reproduces the same schedule
if (now.Date == start.Date) if (now.Date == start.Date)
{ {
continue; continue;
} }
// from here on, a refresh has to have somewhere to rewind to
bool hasCheckpointForToday = playout.ProgramScheduleAnchors.Any(a => bool hasCheckpointForToday = playout.ProgramScheduleAnchors.Any(a =>
a.AnchorDateOffset.HasValue && a.AnchorDateOffset.Value.Date == now.Date); a.AnchorDateOffset.HasValue && a.AnchorDateOffset.Value.Date == now.Date);
@ -74,8 +67,7 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase
[Test] [Test]
public async Task Refresh_Should_Not_Reset_Collection_Progress() public async Task Refresh_Should_Not_Reset_Collection_Progress()
{ {
// a collection large enough that the index can't wrap over the whole run, so the // large enough that the index can't wrap, so it measures progress directly
// index is a straightforward measure of how much progress has been made
(PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData(
TimeSpan.FromHours(6), TimeSpan.FromHours(6),
multipleCount: "3", multipleCount: "3",
@ -83,8 +75,7 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase
DateTimeOffset start = LocalTime(20); DateTimeOffset start = LocalTime(20);
// three weeks of hourly builds, so that progress is far enough along to be clearly // long enough that progress is clearly distinguishable from a playout that restarted
// distinguishable from a playout that restarted at the beginning
for (var hour = 0; hour < 24 * 21; hour++) for (var hour = 0; hour < 24 * 21; hour++)
{ {
await builder.Build( await builder.Build(
@ -107,9 +98,8 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase
result.IsRight.ShouldBeTrue(); result.IsRight.ShouldBeTrue();
// a refresh rewinds to the start of today, so the index legitimately moves back by // rewinding to the start of today legitimately moves the index back by up to a day; a refresh
// up to a day. a refresh that lost its checkpoints restarts at 0 and only advances // that lost its checkpoints lands an order of magnitude lower
// through the build window, landing an order of magnitude lower
int after = HeadIndex(playout); int after = HeadIndex(playout);
after.ShouldBeGreaterThan( after.ShouldBeGreaterThan(
before / 2, before / 2,
@ -119,8 +109,7 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase
[Test] [Test]
public async Task Continue_Should_Keep_A_Checkpoint_For_Every_Day_In_The_Build_Window() public async Task Continue_Should_Keep_A_Checkpoint_For_Every_Day_In_The_Build_Window()
{ {
// shaped like the playouts that were missing checkpoints in a real user database: a week // shaped like the playouts that were missing checkpoints in a real user database
// long build window, shuffled schedule items, and blocks of five ~100 minute items
(PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData( (PlayoutBuilder builder, Playout playout, PlayoutReferenceData referenceData) = MultipleTestData(
TimeSpan.FromMinutes(100), TimeSpan.FromMinutes(100),
multipleCount: "5", multipleCount: "5",
@ -145,9 +134,7 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase
result.IsRight.ShouldBeTrue($"build failed at {now:yyyy-MM-dd HH:mm}"); result.IsRight.ShouldBeTrue($"build failed at {now:yyyy-MM-dd HH:mm}");
// every day the playout covers needs a checkpoint, so that a refresh on any of them has // every day covered needs a checkpoint, except the build's own first day; past days are pruned
// somewhere to rewind to. the build's own first day is the one exception, and days
// already in the past are pruned
DateTime firstExpected = now.Date > start.Date ? now.Date : start.Date.AddDays(1); DateTime firstExpected = now.Date > start.Date ? now.Date : start.Date.AddDays(1);
DateTime lastExpected = now.AddDays(WeekOfDaysToBuild).Date; DateTime lastExpected = now.AddDays(WeekOfDaysToBuild).Date;
@ -263,14 +250,9 @@ public class MultiDayBuildTests : PlayoutBuilderTestBase
return (builder, playout, referenceData); return (builder, playout, referenceData);
} }
/// <summary> // anchor dates are compared in machine-local time, so the test clock has to be local too or day
/// Anchor dates are compared in machine-local time // boundaries won't line up. that means running in whatever zone the machine is in, hence mid-June,
/// (<see cref="PlayoutProgramScheduleAnchor.AnchorDateOffset" />), so the test clock has to be local // where no zone has a DST transition
/// too, or day boundaries won't line up. That means these tests run in whatever zone the machine is
/// in - UTC on CI, something else locally - so the window below is deliberately placed in mid-June,
/// where no zone has a DST transition. Day-boundary behaviour across a DST change is a separate
/// concern and wants its own test.
/// </summary>
private static DateTimeOffset LocalTime(int hour) private static DateTimeOffset LocalTime(int hour)
{ {
var date = new DateTime(2025, 6, 10, 0, 0, 0, DateTimeKind.Unspecified); var date = new DateTime(2025, 6, 10, 0, 0, 0, DateTimeKind.Unspecified);

175
ErsatzTV.Core.Tests/Scheduling/MarathonEnumeratorTests.cs

@ -0,0 +1,175 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
// marathon content builds a PlaylistEnumerator over one playlist item per group, so it inherits that
// enumerator's save/restore behaviour; these cover the state handling, not the grouping
[TestFixture]
public class MarathonEnumeratorTests
{
private const int Seed = 987654321;
[SetUp]
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
private CancellationToken _cancellationToken;
// a cycle completes when every media item has been played, which resets the index to 0
[Test]
public async Task Marathon_Should_Complete_A_Cycle()
{
List<MediaItem> mediaItems =
[
.. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10),
.. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20)
];
PlaylistEnumerator enumerator = await CreateMarathon(mediaItems);
List<int> indexes = Advance(enumerator, 12);
indexes.ShouldContain(0, "the index never returned to 0, so the cycle never completed");
}
// season 0 is filtered out by the season/episode enumerator but still counted as an item the cycle
// is waiting on, so the cycle can never complete
[Test]
public async Task Marathon_Should_Complete_A_Cycle_With_Specials()
{
List<MediaItem> mediaItems =
[
.. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10),
.. Show(showId: 1, seasonNumber: 0, episodes: 1, firstId: 15),
.. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20)
];
PlaylistEnumerator enumerator = await CreateMarathon(mediaItems);
List<int> indexes = Advance(enumerator, 60);
indexes.ShouldContain(0, "the index never returned to 0, so the cycle never completed");
}
// the flood and duration schedulers hand back a snapshot when an item doesn't fit, so ResetState
// has to actually rewind
[Test]
public async Task Marathon_Should_Rewind_On_ResetState()
{
List<MediaItem> mediaItems =
[
.. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10),
.. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20)
];
PlaylistEnumerator enumerator = await CreateMarathon(mediaItems);
enumerator.MoveNext(Option<DateTimeOffset>.None);
CollectionEnumeratorState snapshot = enumerator.State.Clone();
int expected = enumerator.Current.Map(mi => mi.Id).IfNone(-1);
enumerator.MoveNext(Option<DateTimeOffset>.None);
enumerator.MoveNext(Option<DateTimeOffset>.None);
enumerator.ResetState(snapshot);
enumerator.Current.Map(mi => mi.Id).IfNone(-1).ShouldBe(expected);
}
[Test]
public async Task Marathon_Should_Resume_Where_It_Left_Off()
{
List<MediaItem> mediaItems =
[
.. Show(showId: 1, seasonNumber: 1, episodes: 3, firstId: 10),
.. Show(showId: 2, seasonNumber: 1, episodes: 3, firstId: 20)
];
PlaylistEnumerator reference = await CreateMarathon(mediaItems);
var sequence = new List<int>();
var states = new List<CollectionEnumeratorState>();
for (var i = 0; i < 12; i++)
{
states.Add(reference.State.Clone());
sequence.Add(reference.Current.Map(mi => mi.Id).IfNone(-1));
reference.MoveNext(Option<DateTimeOffset>.None);
}
var mismatches = new List<string>();
for (var i = 0; i < states.Count; i++)
{
PlaylistEnumerator resumed = await CreateMarathon(mediaItems, states[i]);
int actual = resumed.Current.Map(mi => mi.Id).IfNone(-1);
if (actual != sequence[i])
{
mismatches.Add($"position {i} (index {states[i].Index}): expected {sequence[i]}, got {actual}");
}
}
mismatches.ShouldBeEmpty(string.Join("; ", mismatches));
}
private async Task<PlaylistEnumerator> CreateMarathon(
List<MediaItem> mediaItems,
CollectionEnumeratorState state = null)
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var helper = new MarathonHelper(repo);
Option<PlaylistEnumerator> maybeEnumerator = await helper.GetEnumerator(
mediaItems,
MarathonGroupBy.Show,
marathonShuffleGroups: false,
marathonShuffleItems: false,
marathonBatchSize: Option<int>.None,
state ?? new CollectionEnumeratorState { Seed = Seed, Index = 0 },
_cancellationToken);
return maybeEnumerator.IfNone(() => throw new InvalidOperationException("no marathon enumerator"));
}
private static List<int> Advance(PlaylistEnumerator enumerator, int count)
{
var indexes = new List<int>();
for (var i = 0; i < count; i++)
{
enumerator.MoveNext(Option<DateTimeOffset>.None);
indexes.Add(enumerator.State.Index);
}
return indexes;
}
private static List<MediaItem> Show(int showId, int seasonNumber, int episodes, int firstId)
{
var seasonId = showId * 100 + seasonNumber;
var season = new Season { Id = seasonId, ShowId = showId, SeasonNumber = seasonNumber };
return Enumerable.Range(0, episodes)
.Map(i => (MediaItem)new Episode
{
Id = firstId + i,
Season = season,
SeasonId = seasonId,
EpisodeMetadata = [new EpisodeMetadata { EpisodeNumber = i + 1 }],
MediaVersions =
[
new MediaVersion
{
Duration = TimeSpan.FromMinutes(30),
MediaFiles = [new MediaFile { Path = $"/fake/path/{firstId + i}" }],
Chapters = []
}
]
})
.ToList();
}
}

67
ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs

@ -457,6 +457,73 @@ public class PlaylistEnumeratorTests
continued.ShouldBe(reference.Skip(6).Take(6)); continued.ShouldBe(reference.Skip(6).Take(6));
} }
// a rebuild only has the persisted seed and index, so a shuffle that depends on anything else
// diverges from the playout the previous build was part way through
[Test]
public async Task Shuffled_Playlist_Should_Resume_The_Same_Way_After_A_Cycle()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
// single-item entries, so each move names its own playlist item
Dictionary<PlaylistItem, List<MediaItem>> BuildMap() =>
Range(1, 4).ToDictionary(
i => new PlaylistItem
{
Id = i,
Index = i - 1,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = i
},
i => new List<MediaItem> { FakeMovie(i) });
const int SEED = 12345;
PlaylistEnumerator live = await PlaylistEnumerator.Create(
repo,
BuildMap(),
new CollectionEnumeratorState { Seed = SEED, Index = 0 },
shufflePlaylistItems: true,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
// past the end of the first cycle, which reseeds and reshuffles
for (var i = 0; i < 6; i++)
{
live.MoveNext(Option<DateTimeOffset>.None);
}
CollectionEnumeratorState persisted = live.State.Clone();
persisted.Seed.ShouldNotBe(SEED, "the cycle should have completed and reseeded");
var expected = new List<int>();
for (var i = 0; i < 4; i++)
{
expected.AddRange(live.Current.Map(mi => mi.Id));
live.MoveNext(Option<DateTimeOffset>.None);
}
PlaylistEnumerator rebuilt = await PlaylistEnumerator.Create(
repo,
BuildMap(),
persisted.Clone(),
shufflePlaylistItems: true,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
var continued = new List<int>();
for (var i = 0; i < 4; i++)
{
continued.AddRange(rebuilt.Current.Map(mi => mi.Id));
rebuilt.MoveNext(Option<DateTimeOffset>.None);
}
continued.ShouldBe(expected);
}
private static Movie FakeMovie(int id) => new() private static Movie FakeMovie(int id) => new()
{ {
Id = id, Id = id,

7
ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs

@ -134,11 +134,8 @@ public class ShuffledContentTests
shuffledContent.State.Seed.ShouldNotBe(MagicSeed); shuffledContent.State.Seed.ShouldNotBe(MagicSeed);
} }
/// <summary> // MoveNext wraps at the flattened item count, so any index below that is valid state the
/// MoveNext walks (and wraps at) the flattened item count, so an index up to one less than // enumerator produced itself
/// that is valid state. The constructor validates against the group count instead, so any
/// index at or above it is thrown away even though the enumerator itself produced it.
/// </summary>
[Test] [Test]
public void State_Should_Not_Reset_When_Valid_For_Grouped_Items() public void State_Should_Not_Reset_When_Valid_For_Grouped_Items()
{ {

115
ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs

@ -14,6 +14,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
private CloneableRandom _random; private CloneableRandom _random;
private bool _shufflePlaylistItems; private bool _shufflePlaylistItems;
private List<EnumeratorPlayAllCount> _sortedEnumerators; private List<EnumeratorPlayAllCount> _sortedEnumerators;
// a cycle start is the only position that can be restored directly; see RewindToCycleStart
private List<EnumeratorPlayAllCount> _enumeratorsInPlaylistOrder;
private Dictionary<IMediaCollectionEnumerator, CollectionEnumeratorState> _childStatesAtCycleStart;
private int _itemsTakenFromCurrent; private int _itemsTakenFromCurrent;
private Option<int> _batchSize = Option<int>.None; private Option<int> _batchSize = Option<int>.None;
@ -31,10 +36,16 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
public int EnumeratorIndex { get; private set; } public int EnumeratorIndex { get; private set; }
// index and seed don't describe a playlist position, so the only way back to one is to replay to it
public void ResetState(CollectionEnumeratorState state) public void ResetState(CollectionEnumeratorState state)
{ {
// seed doesn't matter here // nothing has moved since this state was handed out, so there is nothing to rewind
State.Index = state.Index; if (State.Index != state.Index || State.Seed != state.Seed)
{
RewindToCycleStart(state.Seed);
ReplayTo(state.Index);
}
State.Started = state.Started; State.Started = state.Started;
} }
@ -147,14 +158,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
foreach (PlaylistItem playlistItem in playlistItemMap.Keys.OrderBy(i => i.Index)) foreach (PlaylistItem playlistItem in playlistItemMap.Keys.OrderBy(i => i.Index))
{ {
List<MediaItem> items = playlistItemMap[playlistItem]; List<MediaItem> items = playlistItemMap[playlistItem];
foreach (MediaItem mediaItem in items)
{
result._allMediaItemIds.Add(mediaItem.Id);
if (playlistItem.IncludeInProgramGuide) // an item that can never play would leave the cycle permanently incomplete
if (playlistItem.PlaybackOrder is PlaybackOrder.SeasonEpisode)
{ {
result._idsToIncludeInEPG.Add(mediaItem.Id); items = SeasonEpisodeMediaCollectionEnumerator.Playable(items);
}
} }
var collectionKey = CollectionKey.ForPlaylistItem(playlistItem); var collectionKey = CollectionKey.ForPlaylistItem(playlistItem);
@ -162,6 +170,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
{ {
result._sortedEnumerators.Add( result._sortedEnumerators.Add(
new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count)); new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count));
result.TrackMediaItemIds(items, playlistItem.IncludeInProgramGuide);
continue; continue;
} }
@ -211,7 +220,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
} }
enumerator = new SeasonEpisodeMediaCollectionEnumerator(items, initState); enumerator = new SeasonEpisodeMediaCollectionEnumerator(items, initState);
// season, episode will filter out season 0, so we may get an empty enumerator back // season 0 is already gone, so this item had nothing else in it
if (enumerator.Count == 0) if (enumerator.Count == 0)
{ {
enumerator = null; enumerator = null;
@ -229,6 +238,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
enumeratorMap.Add(collectionKey, enumerator); enumeratorMap.Add(collectionKey, enumerator);
result._sortedEnumerators.Add( result._sortedEnumerators.Add(
new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count)); new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count));
result.TrackMediaItemIds(items, playlistItem.IncludeInProgramGuide);
} }
} }
@ -240,6 +250,13 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
.OrderBy(identity) .OrderBy(identity)
.HeadOrNone(); .HeadOrNone();
// captured before anything moves, so RewindToCycleStart can put the children back
result._enumeratorsInPlaylistOrder = [.. result._sortedEnumerators];
result._childStatesAtCycleStart = result._sortedEnumerators
.Map(e => e.Enumerator)
.Distinct()
.ToDictionary(e => e, e => e.State.Clone());
result._random = new CloneableRandom(state.Seed); result._random = new CloneableRandom(state.Seed);
if (shufflePlaylistItems) if (shufflePlaylistItems)
@ -256,16 +273,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
state.Index = 0; state.Index = 0;
} }
while (result.State.Index < state.Index) result.ReplayTo(state.Index);
{
result.MoveNext(Option<DateTimeOffset>.None);
// previous state is no longer valid; playlist now has fewer items
if (result.State.Index == 0)
{
break;
}
}
var childEnumerators = new List<PlaylistEnumeratorCollectionKey>(); var childEnumerators = new List<PlaylistEnumeratorCollectionKey>();
foreach ((IMediaCollectionEnumerator enumerator, _, _) in result._sortedEnumerators) foreach ((IMediaCollectionEnumerator enumerator, _, _) in result._sortedEnumerators)
@ -281,15 +289,76 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
return result; return result;
} }
private void TrackMediaItemIds(List<MediaItem> items, bool includeInProgramGuide)
{
foreach (MediaItem mediaItem in items)
{
_allMediaItemIds.Add(mediaItem.Id);
if (includeInProgramGuide)
{
_idsToIncludeInEPG.Add(mediaItem.Id);
}
}
}
// must leave exactly what Create leaves for this seed, or replaying from here won't match a rebuild
private void RewindToCycleStart(int seed)
{
foreach ((IMediaCollectionEnumerator enumerator, CollectionEnumeratorState childState) in
_childStatesAtCycleStart)
{
enumerator.ResetState(childState.Clone());
}
_sortedEnumerators = [.. _enumeratorsInPlaylistOrder];
_random = new CloneableRandom(seed);
if (_shufflePlaylistItems)
{
_sortedEnumerators = ShufflePlaylistItems();
}
_remainingMediaItemIds.Clear();
_remainingMediaItemIds.UnionWith(_allMediaItemIds);
EnumeratorIndex = 0;
_itemsTakenFromCurrent = 0;
State.Seed = seed;
State.Index = 0;
}
private void ReplayTo(int index)
{
if (_sortedEnumerators.Count == 0)
{
return;
}
while (State.Index < index)
{
MoveNext(Option<DateTimeOffset>.None);
// previous state is no longer valid; playlist now has fewer items
if (State.Index == 0)
{
break;
}
}
}
// shuffles playlist order rather than current order, so the result depends only on that order and the
// seed - all a rebuild has to work from
private List<EnumeratorPlayAllCount> ShufflePlaylistItems() private List<EnumeratorPlayAllCount> ShufflePlaylistItems()
{ {
if (_sortedEnumerators.Count < 3) if (_enumeratorsInPlaylistOrder.Count < 3)
{ {
return _sortedEnumerators; return [.. _enumeratorsInPlaylistOrder];
} }
EnumeratorPlayAllCount[] copy = _sortedEnumerators.ToArray(); EnumeratorPlayAllCount[] copy = _enumeratorsInPlaylistOrder.ToArray();
EnumeratorPlayAllCount last = _sortedEnumerators.Last(); EnumeratorPlayAllCount last = _enumeratorsInPlaylistOrder.Last();
do do
{ {

28
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -169,9 +169,8 @@ public class PlayoutBuilder : IPlayoutBuilder
// _logger.LogDebug("All anchors: {@Anchors}", playout.ProgramScheduleAnchors); // _logger.LogDebug("All anchors: {@Anchors}", playout.ProgramScheduleAnchors);
// remove the "continue" anchors (null anchor date), which hold the state at the head of // these have to go rather than just be ignored; GetMediaCollectionEnumerator ranks a null
// the playout; they have to go rather than just be ignored, because GetMediaCollectionEnumerator // anchor date above every checkpoint, and we want to rewind to today's checkpoint
// ranks a null anchor date above every checkpoint and we want to rewind to today's checkpoint
playout.ProgramScheduleAnchors.RemoveAll(a => a.AnchorDate is null); playout.ProgramScheduleAnchors.RemoveAll(a => a.AnchorDate is null);
// _logger.LogDebug("Checkpoint anchors: {@Anchors}", playout.ProgramScheduleAnchors); // _logger.LogDebug("Checkpoint anchors: {@Anchors}", playout.ProgramScheduleAnchors);
@ -457,8 +456,8 @@ public class PlayoutBuilder : IPlayoutBuilder
return result; return result;
} }
// build one whole day at a time; this is also how alternate schedules switch per day, // one whole day at a time; this is also how alternate schedules switch, since the active
// since the active schedule is selected once per pass // schedule is selected once per pass
while (finish < playoutFinish) while (finish < playoutFinish)
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
@ -497,7 +496,6 @@ public class PlayoutBuilder : IPlayoutBuilder
if (start < playoutFinish) if (start < playoutFinish)
{ {
// build the remaining partial day
_logger.LogDebug("Building final playout from {Start} to {Finish}", start, playoutFinish); _logger.LogDebug("Building final playout from {Start} to {Finish}", start, playoutFinish);
Either<BaseError, PlayoutBuildResult> buildResult = await BuildPlayoutItemsForPass( Either<BaseError, PlayoutBuildResult> buildResult = await BuildPlayoutItemsForPass(
playout, playout,
@ -901,10 +899,8 @@ public class PlayoutBuilder : IPlayoutBuilder
result.AddedItems.AddRange(playoutItems); result.AddedItems.AddRange(playoutItems);
// save a checkpoint anchor whenever scheduling crosses a local midnight; this has to // has to happen here rather than at the end of a pass; pass boundaries only aspirationally
// happen here rather than at the end of a pass, because pass boundaries only // line up with midnights, and the final partial pass never ends on one
// aspirationally line up with midnights - the final partial pass never ends on one, and
// a long item can carry a whole-day pass well past its own boundary
if (nextState.CurrentTime.ToLocalTime().Date > playoutBuilderState.CurrentTime.ToLocalTime().Date) if (nextState.CurrentTime.ToLocalTime().Date > playoutBuilderState.CurrentTime.ToLocalTime().Date)
{ {
SaveCheckpointAnchors(playout, collectionEnumerators, nextState.CurrentTime); SaveCheckpointAnchors(playout, collectionEnumerators, nextState.CurrentTime);
@ -1212,11 +1208,7 @@ public class PlayoutBuilder : IPlayoutBuilder
&& anchor.PlaylistId == collectionKey.PlaylistId && anchor.PlaylistId == collectionKey.PlaylistId
&& anchor.SearchQuery == collectionKey.SearchQuery; && anchor.SearchQuery == collectionKey.SearchQuery;
/// <summary> // refresh rewinds to the checkpoint dated today, so every local day of the playout needs one
/// Records the collection progress as of <paramref name="checkpointTime" />, which is the first
/// resumable point at or after a local midnight. Refresh rewinds to the checkpoint dated today,
/// so every local day of the playout needs one.
/// </summary>
private static void SaveCheckpointAnchors( private static void SaveCheckpointAnchors(
Playout playout, Playout playout,
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators, Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
@ -1227,15 +1219,13 @@ public class PlayoutBuilder : IPlayoutBuilder
foreach ((CollectionKey collectionKey, IMediaCollectionEnumerator enumerator) in collectionEnumerators) foreach ((CollectionKey collectionKey, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{ {
// one checkpoint per collection key per local date; rebuilding a day that already has a // rebuilding a day replaces its checkpoint; the new state is the one matching the new items
// checkpoint replaces it, since the new state is the one that matches the new items
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault(a => Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault(a =>
AnchorMatchesCollectionKey(a, collectionKey) AnchorMatchesCollectionKey(a, collectionKey)
&& a.AnchorDateOffset.HasValue && a.AnchorDateOffset.HasValue
&& a.AnchorDateOffset.Value.Date == checkpointDate); && a.AnchorDateOffset.Value.Date == checkpointDate);
// the enumerator keeps mutating its own state as the build continues, so the checkpoint // the enumerator keeps mutating its own state as the build continues
// needs a copy of it, frozen at this instant
CollectionEnumeratorState state = enumerator.State.Clone(); CollectionEnumeratorState state = enumerator.State.Clone();
foreach (PlayoutProgramScheduleAnchor existing in maybeExisting) foreach (PlayoutProgramScheduleAnchor existing in maybeExisting)

7
ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs

@ -15,8 +15,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
{ {
CurrentIncludeInProgramGuide = Option<bool>.None; CurrentIncludeInProgramGuide = Option<bool>.None;
_sortedMediaItems = mediaItems _sortedMediaItems = Playable(mediaItems)
.Filter(mi => (mi is not Episode episode) || (episode.Season?.SeasonNumber ?? 0) > 0)
.OrderBy(identity, new SeasonEpisodeMediaComparer()).ToList(); .OrderBy(identity, new SeasonEpisodeMediaComparer()).ToList();
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() => _lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() =>
_sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone()); _sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
@ -35,6 +34,10 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
} }
} }
// shared with callers that need to know what this will play, not what they handed it
public static List<MediaItem> Playable(IEnumerable<MediaItem> mediaItems) =>
mediaItems.Filter(mi => (mi is not Episode episode) || (episode.Season?.SeasonNumber ?? 0) > 0).ToList();
public void ResetState(CollectionEnumeratorState state) public void ResetState(CollectionEnumeratorState state)
{ {
// seed doesn't matter here // seed doesn't matter here

Loading…
Cancel
Save