mirror of https://github.com/ErsatzTV/ErsatzTV.git
6 changed files with 539 additions and 52 deletions
@ -0,0 +1,497 @@
@@ -0,0 +1,497 @@
|
||||
using System.Collections.Immutable; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Domain.Scheduling; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Scheduling; |
||||
using ErsatzTV.Core.Scheduling; |
||||
using ErsatzTV.Core.Scheduling.BlockScheduling; |
||||
using ErsatzTV.Core.Scheduling.Engine; |
||||
using ErsatzTV.Core.Scheduling.YamlScheduling; |
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; |
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Models; |
||||
using Microsoft.Extensions.Logging; |
||||
using Microsoft.Extensions.Logging.Abstractions; |
||||
using NSubstitute; |
||||
using NUnit.Framework; |
||||
using Shouldly; |
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling; |
||||
|
||||
// the scripted, block and yaml schedulers each restore a playlist from history with the same 3 lines.
|
||||
// a playlist position is the index of the primary history row; a child index counts the items of one
|
||||
// collection, and PlaylistEnumerator.ResetState with a child index puts every child back to the cycle
|
||||
// start
|
||||
[TestFixture] |
||||
public class PlaylistHistoryTests |
||||
{ |
||||
private const int PlayoutSeed = 12345; |
||||
|
||||
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); |
||||
private static readonly TimeSpan ItemDuration = TimeSpan.FromMinutes(30); |
||||
|
||||
[SetUp] |
||||
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token; |
||||
|
||||
private CancellationToken _cancellationToken; |
||||
|
||||
// a user reported this case: a sequential schedule with marathon pools repeated some episodes and
|
||||
// skipped others after a rebuild
|
||||
[Test] |
||||
public async Task Marathon_Should_Resume_Where_The_Previous_Build_Stopped() |
||||
{ |
||||
const int BUILD_ONE_COUNT = 20; |
||||
const int COMPARE_COUNT = 12; |
||||
|
||||
IMediaCollectionRepository repo = FakeShowRepository(shows: 6, episodesPerShow: 8); |
||||
YamlPlayoutContentMarathonItem marathon = MarathonContent(shows: 6); |
||||
var definition = new YamlPlayoutDefinition { Content = [marathon] }; |
||||
var playout = new Playout { Id = 1, Seed = PlayoutSeed, PlayoutHistory = [] }; |
||||
|
||||
var buildOneContext = new YamlPlayoutContext(playout, definition, 1) { CurrentTime = Start }; |
||||
var buildOneCache = new EnumeratorCache(repo, NullLogger.Instance); |
||||
PlaylistEnumerator buildOne = await GetPlaylistEnumerator(buildOneCache, buildOneContext, marathon.Key); |
||||
|
||||
var playedInBuildOne = new List<int>(); |
||||
var history = new List<PlayoutHistory>(); |
||||
DateTimeOffset currentTime = Start; |
||||
for (var i = 0; i < BUILD_ONE_COUNT; i++) |
||||
{ |
||||
playedInBuildOne.Add(CurrentId(buildOne)); |
||||
history.AddRange(RecordHistory(buildOneContext, marathon.Key, buildOne, currentTime)); |
||||
buildOne.MoveNext(currentTime); |
||||
currentTime += ItemDuration; |
||||
} |
||||
|
||||
// the live enumerator never lost its position, so what it plays next is the correct order
|
||||
List<int> expected = Take(buildOne, COMPARE_COUNT); |
||||
|
||||
// build 2 starts with a new enumerator that has only the saved history
|
||||
var buildTwoContext = new YamlPlayoutContext(playout, definition, 1) { CurrentTime = currentTime }; |
||||
var buildTwoCache = new EnumeratorCache(repo, NullLogger.Instance); |
||||
var applyHistory = new YamlPlayoutApplyHistoryHandler(buildTwoCache); |
||||
|
||||
bool applied = await applyHistory.Handle( |
||||
history, |
||||
buildTwoContext, |
||||
marathon, |
||||
NullLogger<SequentialPlayoutBuilder>.Instance, |
||||
_cancellationToken); |
||||
|
||||
applied.ShouldBeTrue(); |
||||
|
||||
PlaylistEnumerator buildTwo = await GetPlaylistEnumerator(buildTwoCache, buildTwoContext, marathon.Key); |
||||
List<int> actual = Take(buildTwo, COMPARE_COUNT); |
||||
|
||||
actual.ShouldBe( |
||||
expected, |
||||
$"build 1 played [{string.Join(", ", playedInBuildOne)}]"); |
||||
} |
||||
|
||||
// the block scheduler restores playlist filler with its own copy of the same 3 lines
|
||||
[Test] |
||||
public async Task Block_Playlist_Filler_Should_Resume_Where_The_Previous_Build_Stopped() |
||||
{ |
||||
const int PLAYLIST_ID = 7; |
||||
const int BUILD_ONE_COUNT = 11; |
||||
const int COMPARE_COUNT = 8; |
||||
const string HISTORY_KEY = "block-playlist-filler"; |
||||
|
||||
IMediaCollectionRepository repo = FakePlaylistRepository(PLAYLIST_ID, collections: 3, itemsPerCollection: 4); |
||||
|
||||
PlaylistEnumerator buildOne = (PlaylistEnumerator)await BlockPlayoutEnumerator.PlaylistForFiller( |
||||
repo, |
||||
PLAYLIST_ID, |
||||
Start, |
||||
PlayoutSeed, |
||||
[], |
||||
seedOffset: 0, |
||||
HISTORY_KEY, |
||||
_cancellationToken); |
||||
|
||||
var history = new List<PlayoutHistory>(); |
||||
DateTimeOffset currentTime = Start; |
||||
for (var i = 0; i < BUILD_ONE_COUNT; i++) |
||||
{ |
||||
history.Add(BlockFillerHistoryFor(buildOne, HISTORY_KEY, currentTime)); |
||||
buildOne.MoveNext(currentTime); |
||||
currentTime += ItemDuration; |
||||
} |
||||
|
||||
List<int> expected = Take(buildOne, COMPARE_COUNT); |
||||
|
||||
var buildTwo = (PlaylistEnumerator)await BlockPlayoutEnumerator.PlaylistForFiller( |
||||
repo, |
||||
PLAYLIST_ID, |
||||
currentTime, |
||||
PlayoutSeed, |
||||
history, |
||||
seedOffset: 0, |
||||
HISTORY_KEY, |
||||
_cancellationToken); |
||||
|
||||
Take(buildTwo, COMPARE_COUNT).ShouldBe(expected); |
||||
} |
||||
|
||||
// one ResetState call for each child would put every child back to the cycle start, so the second
|
||||
// child would discard the position of the first
|
||||
[Test] |
||||
public async Task Applying_History_Should_Restore_Every_Childs_Position() |
||||
{ |
||||
const int BUILD_ONE_COUNT = 11; |
||||
const string HISTORY_KEY = "playlist"; |
||||
|
||||
IMediaCollectionRepository repo = FakePlaylistRepository(playlistId: 1, collections: 3, itemsPerCollection: 4); |
||||
Dictionary<PlaylistItem, List<MediaItem>> itemMap = await repo.GetPlaylistItemMap(1, _cancellationToken); |
||||
|
||||
SchedulingEngine engine = CreateEngine(repo); |
||||
PlaylistEnumerator buildOne = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); |
||||
|
||||
DateTimeOffset currentTime = Start; |
||||
var history = new List<PlayoutHistory>(); |
||||
for (var i = 0; i < BUILD_ONE_COUNT; i++) |
||||
{ |
||||
history.AddRange(ScriptedHistoryFor(engine, buildOne, HISTORY_KEY, currentTime)); |
||||
buildOne.MoveNext(currentTime); |
||||
currentTime += ItemDuration; |
||||
} |
||||
|
||||
List<int> expected = ChildItemIds(buildOne); |
||||
|
||||
// the children need different positions, or a rewind to the cycle start would not show
|
||||
expected.Distinct().Count().ShouldBeGreaterThan(1); |
||||
|
||||
PlaylistEnumerator buildTwo = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); |
||||
ApplyScriptedHistory(engine, history, currentTime, HISTORY_KEY, itemMap, buildTwo); |
||||
|
||||
ChildItemIds(buildTwo).ShouldBe(expected, "at least one child was rewound to the cycle start"); |
||||
} |
||||
|
||||
// the index of the primary history row is the cycle position of the playlist, not the slot number
|
||||
// of the current child
|
||||
[Test] |
||||
public async Task Playlist_Should_Restore_Its_Cycle_Position_From_History() |
||||
{ |
||||
const int BUILD_ONE_COUNT = 7; |
||||
|
||||
IMediaCollectionRepository repo = FakePlaylistRepository(playlistId: 1, collections: 3, itemsPerCollection: 4); |
||||
Dictionary<PlaylistItem, List<MediaItem>> itemMap = await repo.GetPlaylistItemMap(1, _cancellationToken); |
||||
|
||||
PlaylistEnumerator buildOne = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); |
||||
|
||||
DateTimeOffset currentTime = Start; |
||||
var history = new List<PlayoutHistory>(); |
||||
for (var i = 0; i < BUILD_ONE_COUNT; i++) |
||||
{ |
||||
history.Add(BlockFillerHistoryFor(buildOne, "playlist", currentTime)); |
||||
buildOne.MoveNext(currentTime); |
||||
currentTime += ItemDuration; |
||||
} |
||||
|
||||
int expectedPlaylistIndex = buildOne.State.Index; |
||||
|
||||
var buildTwo = (PlaylistEnumerator)await BlockPlayoutEnumerator.PlaylistForFiller( |
||||
repo, |
||||
1, |
||||
currentTime, |
||||
PlayoutSeed, |
||||
history, |
||||
seedOffset: 0, |
||||
"playlist", |
||||
_cancellationToken); |
||||
|
||||
buildTwo.State.Index.ShouldBe(expectedPlaylistIndex); |
||||
} |
||||
|
||||
// the scripted scheduler reaches the same 3 lines through SchedulingEngine.ApplyPlaylistHistory
|
||||
[Test] |
||||
public async Task Scripted_Playlist_Should_Resume_Where_The_Previous_Build_Stopped() |
||||
{ |
||||
const int BUILD_ONE_COUNT = 11; |
||||
const int COMPARE_COUNT = 8; |
||||
const string HISTORY_KEY = "scripted-playlist"; |
||||
|
||||
IMediaCollectionRepository repo = FakePlaylistRepository(playlistId: 1, collections: 3, itemsPerCollection: 4); |
||||
Dictionary<PlaylistItem, List<MediaItem>> itemMap = await repo.GetPlaylistItemMap(1, _cancellationToken); |
||||
|
||||
SchedulingEngine engine = CreateEngine(repo); |
||||
PlaylistEnumerator buildOne = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); |
||||
|
||||
DateTimeOffset currentTime = Start; |
||||
var history = new List<PlayoutHistory>(); |
||||
for (var i = 0; i < BUILD_ONE_COUNT; i++) |
||||
{ |
||||
history.AddRange(ScriptedHistoryFor(engine, buildOne, HISTORY_KEY, currentTime)); |
||||
buildOne.MoveNext(currentTime); |
||||
currentTime += ItemDuration; |
||||
} |
||||
|
||||
List<int> expected = Take(buildOne, COMPARE_COUNT); |
||||
|
||||
PlaylistEnumerator buildTwo = await CreatePlaylistEnumerator(repo, itemMap, _cancellationToken); |
||||
ApplyScriptedHistory(engine, history, currentTime, HISTORY_KEY, itemMap, buildTwo); |
||||
|
||||
Take(buildTwo, COMPARE_COUNT).ShouldBe(expected); |
||||
} |
||||
|
||||
private static async Task<PlaylistEnumerator> CreatePlaylistEnumerator( |
||||
IMediaCollectionRepository repo, |
||||
Dictionary<PlaylistItem, List<MediaItem>> itemMap, |
||||
CancellationToken cancellationToken) => |
||||
await PlaylistEnumerator.Create( |
||||
repo, |
||||
itemMap, |
||||
new CollectionEnumeratorState { Seed = PlayoutSeed, Index = 0 }, |
||||
shufflePlaylistItems: false, |
||||
batchSize: Option<int>.None, |
||||
randomStartPoint: false, |
||||
cancellationToken); |
||||
|
||||
private static async Task<PlaylistEnumerator> GetPlaylistEnumerator( |
||||
EnumeratorCache cache, |
||||
YamlPlayoutContext context, |
||||
string contentKey) |
||||
{ |
||||
Option<IMediaCollectionEnumerator> maybeEnumerator = |
||||
await cache.GetCachedEnumeratorForContent(context, contentKey, CancellationToken.None); |
||||
|
||||
return maybeEnumerator |
||||
.Map(e => e as PlaylistEnumerator) |
||||
.IfNone(() => throw new InvalidOperationException("no playlist enumerator")); |
||||
} |
||||
|
||||
private static List<PlayoutHistory> RecordHistory( |
||||
YamlPlayoutContext context, |
||||
string contentKey, |
||||
PlaylistEnumerator enumerator, |
||||
DateTimeOffset startTime) |
||||
{ |
||||
var playoutItem = new PlayoutItem |
||||
{ |
||||
Start = startTime.UtcDateTime, |
||||
Finish = (startTime + ItemDuration).UtcDateTime |
||||
}; |
||||
|
||||
MediaItem mediaItem = enumerator.Current.IfNone(() => throw new InvalidOperationException("no current item")); |
||||
|
||||
return HistoryRecorder.Record( |
||||
context, |
||||
contentKey, |
||||
enumerator, |
||||
playoutItem, |
||||
mediaItem, |
||||
NullLogger<SequentialPlayoutBuilder>.Instance); |
||||
} |
||||
|
||||
private static SchedulingEngine CreateEngine(IMediaCollectionRepository repo) => |
||||
new( |
||||
repo, |
||||
Substitute.For<IGraphicsElementRepository>(), |
||||
Substitute.For<IChannelRepository>(), |
||||
NullLogger<SchedulingEngine>.Instance); |
||||
|
||||
private static List<PlayoutHistory> ScriptedHistoryFor( |
||||
SchedulingEngine engine, |
||||
PlaylistEnumerator enumerator, |
||||
string historyKey, |
||||
DateTimeOffset startTime) |
||||
{ |
||||
var playoutItem = new PlayoutItem |
||||
{ |
||||
Start = startTime.UtcDateTime, |
||||
Finish = (startTime + ItemDuration).UtcDateTime |
||||
}; |
||||
|
||||
MediaItem mediaItem = enumerator.Current.IfNone(() => throw new InvalidOperationException("no current item")); |
||||
|
||||
return engine.GetHistoryForItem( |
||||
new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None), |
||||
playoutItem, |
||||
mediaItem); |
||||
} |
||||
|
||||
private static void ApplyScriptedHistory( |
||||
SchedulingEngine engine, |
||||
List<PlayoutHistory> history, |
||||
DateTimeOffset currentTime, |
||||
string historyKey, |
||||
Dictionary<PlaylistItem, List<MediaItem>> itemMap, |
||||
PlaylistEnumerator enumerator) |
||||
{ |
||||
// ApplyPlaylistHistory reads the history and the current time from engine state
|
||||
engine.WithReferenceData( |
||||
new PlayoutReferenceData( |
||||
new Channel(Guid.NewGuid()) { Id = 1, Number = "1", Name = "Playlist history test" }, |
||||
Option<Deco>.None, |
||||
[], |
||||
[], |
||||
null, |
||||
[], |
||||
history, |
||||
TimeSpan.Zero)); |
||||
engine.BuildBetween(currentTime, currentTime.AddDays(1)); |
||||
|
||||
engine.ApplyPlaylistHistory( |
||||
historyKey, |
||||
itemMap.ToImmutableDictionary(x => CollectionKey.ForPlaylistItem(x.Key), x => x.Value), |
||||
enumerator); |
||||
} |
||||
|
||||
// BlockPlayoutFillerBuilder writes one row for each item and no child rows, so the index of the
|
||||
// playlist is the only position it saves
|
||||
private static PlayoutHistory BlockFillerHistoryFor( |
||||
PlaylistEnumerator enumerator, |
||||
string historyKey, |
||||
DateTimeOffset startTime) => |
||||
new() |
||||
{ |
||||
PlaybackOrder = PlaybackOrder.Shuffle, |
||||
Index = enumerator.State.Index, |
||||
When = startTime.UtcDateTime, |
||||
Finish = (startTime + ItemDuration).UtcDateTime, |
||||
Key = historyKey, |
||||
Details = HistoryDetails.ForMediaItem( |
||||
enumerator.Current.IfNone(() => throw new InvalidOperationException("no current item"))) |
||||
}; |
||||
|
||||
private static List<int> Take(PlaylistEnumerator enumerator, int count) |
||||
{ |
||||
var result = new List<int>(); |
||||
for (var i = 0; i < count; i++) |
||||
{ |
||||
result.Add(CurrentId(enumerator)); |
||||
enumerator.MoveNext(Option<DateTimeOffset>.None); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
private static int CurrentId(PlaylistEnumerator enumerator) => enumerator.Current.Map(mi => mi.Id).IfNone(-1); |
||||
|
||||
private static List<int> ChildItemIds(PlaylistEnumerator enumerator) => enumerator.ChildEnumerators |
||||
.Map(c => c.Enumerator.Current.Map(mi => mi.Id).IfNone(-1)) |
||||
.ToList(); |
||||
|
||||
private static YamlPlayoutContentMarathonItem MarathonContent(int shows) => |
||||
new() |
||||
{ |
||||
Key = "marathon", |
||||
Marathon = "marathon", |
||||
Guids = Enumerable.Range(1, shows) |
||||
.Map(showId => new YamlPlayoutContentGuid { Source = "imdb", Value = $"show{showId}" }) |
||||
.ToList(), |
||||
GroupBy = "show", |
||||
ShuffleGroups = true, |
||||
ItemOrder = "chronological", |
||||
PlayAllItems = false |
||||
}; |
||||
|
||||
private static IMediaCollectionRepository FakeShowRepository(int shows, int episodesPerShow) |
||||
{ |
||||
Dictionary<string, List<MediaItem>> byGuid = Enumerable.Range(1, shows) |
||||
.ToDictionary( |
||||
showId => $"imdb://show{showId}", |
||||
showId => Episodes(showId, episodesPerShow)); |
||||
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>(); |
||||
repo.GetShowItemsByShowGuids(Arg.Any<List<string>>()) |
||||
.Returns(call => Task.FromResult(((List<string>)call[0]).SelectMany(g => byGuid[g]).ToList())); |
||||
|
||||
return repo; |
||||
} |
||||
|
||||
private static IMediaCollectionRepository FakePlaylistRepository( |
||||
int playlistId, |
||||
int collections, |
||||
int itemsPerCollection) |
||||
{ |
||||
Dictionary<PlaylistItem, List<MediaItem>> itemMap = Enumerable.Range(1, collections) |
||||
.ToDictionary( |
||||
collectionId => new PlaylistItem |
||||
{ |
||||
Id = collectionId, |
||||
Index = collectionId - 1, |
||||
PlaybackOrder = PlaybackOrder.Chronological, |
||||
PlayAll = false, |
||||
CollectionType = CollectionType.Collection, |
||||
CollectionId = collectionId, |
||||
IncludeInProgramGuide = true |
||||
}, |
||||
collectionId => Enumerable.Range(0, itemsPerCollection) |
||||
.Map(i => (MediaItem)FakeMovie(collectionId * 100 + i)) |
||||
.ToList()); |
||||
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>(); |
||||
repo.GetPlaylistItemMap(playlistId, Arg.Any<CancellationToken>()) |
||||
.Returns(_ => Task.FromResult(itemMap)); |
||||
|
||||
return repo; |
||||
} |
||||
|
||||
private static List<MediaItem> Episodes(int showId, int episodes) |
||||
{ |
||||
int seasonId = showId * 100 + 1; |
||||
var season = new Season { Id = seasonId, ShowId = showId, SeasonNumber = 1 }; |
||||
|
||||
return Enumerable.Range(0, episodes) |
||||
.Map(i => (MediaItem)new Episode |
||||
{ |
||||
Id = showId * 100 + i, |
||||
Season = season, |
||||
SeasonId = seasonId, |
||||
EpisodeMetadata = |
||||
[ |
||||
new EpisodeMetadata |
||||
{ |
||||
EpisodeNumber = i + 1, |
||||
ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) |
||||
} |
||||
], |
||||
MediaVersions = |
||||
[ |
||||
new MediaVersion |
||||
{ |
||||
Duration = ItemDuration, |
||||
MediaFiles = [new MediaFile { Path = $"/fake/path/{showId}-{i}" }], |
||||
Chapters = [] |
||||
} |
||||
] |
||||
}) |
||||
.ToList(); |
||||
} |
||||
|
||||
private static Movie FakeMovie(int id) => new() |
||||
{ |
||||
Id = id, |
||||
MediaVersions = [new MediaVersion { Duration = ItemDuration, MediaFiles = [], Chapters = [] }], |
||||
MovieMetadata = |
||||
[ |
||||
new MovieMetadata |
||||
{ |
||||
Title = $"Movie {id}", |
||||
ReleaseDate = new DateTime(2020, 1, 1).AddDays(id) |
||||
} |
||||
] |
||||
}; |
||||
|
||||
// GetHistoryForItem is protected on the real content handler, so this subclass gives the test the
|
||||
// production writer instead of a copy
|
||||
private sealed class HistoryRecorder(EnumeratorCache enumeratorCache) : YamlPlayoutContentHandler(enumeratorCache) |
||||
{ |
||||
public static List<PlayoutHistory> Record( |
||||
YamlPlayoutContext context, |
||||
string contentKey, |
||||
IMediaCollectionEnumerator enumerator, |
||||
PlayoutItem playoutItem, |
||||
MediaItem mediaItem, |
||||
ILogger<SequentialPlayoutBuilder> logger) => |
||||
GetHistoryForItem(context, contentKey, enumerator, playoutItem, mediaItem, logger); |
||||
|
||||
public override Task<bool> Handle( |
||||
YamlPlayoutContext context, |
||||
YamlPlayoutInstruction instruction, |
||||
PlayoutBuildMode mode, |
||||
Func<string, Task> executeSequence, |
||||
ILogger<SequentialPlayoutBuilder> logger, |
||||
CancellationToken cancellationToken) => throw new NotSupportedException(); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue