mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
647 lines
25 KiB
647 lines
25 KiB
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)}]"); |
|
} |
|
|
|
// a shuffled playlist gets a new seed at the end of each cycle. |
|
// 6 shows with 2 episodes give a cycle of 12 items, and build 1 stops in cycle 2. |
|
// the test needs 4 groups or more, because ShufflePlaylistItems refuses an order that starts with |
|
// the last group of the previous order. |
|
[Test] |
|
public async Task Marathon_Should_Resume_Inside_A_Later_Cycle() |
|
{ |
|
const int BUILD_ONE_COUNT = 14; |
|
const int COMPARE_COUNT = 12; |
|
|
|
IMediaCollectionRepository repo = FakeShowRepository(shows: 6, episodesPerShow: 2); |
|
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); |
|
|
|
int seedAtStart = buildOne.State.Seed; |
|
|
|
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 build must cross a cycle end, or the seed does not change and the test shows nothing |
|
buildOne.State.Seed.ShouldNotBe(seedAtStart); |
|
|
|
List<int> expected = Take(buildOne, COMPARE_COUNT); |
|
|
|
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); |
|
} |
|
|
|
// with uneven collection sizes, a cycle can end while a child is part way through its own list. |
|
// a rewind to the cycle start does not make that position again. |
|
// item_order is shuffle by default for a marathon. |
|
// a shuffled child gets a new seed each time it wraps, so the replay cannot make that order again. |
|
[Test] |
|
[TestCase(PlaybackOrder.Chronological)] |
|
[TestCase(PlaybackOrder.Shuffle)] |
|
public async Task Shuffled_Playlist_Of_Uneven_Collections_Should_Resume_Inside_A_Later_Cycle( |
|
PlaybackOrder itemPlaybackOrder) |
|
{ |
|
const string HISTORY_KEY = "uneven-playlist"; |
|
|
|
IMediaCollectionRepository repo = FakeUnevenPlaylistRepository(itemPlaybackOrder); |
|
Dictionary<PlaylistItem, List<MediaItem>> itemMap = await repo.GetPlaylistItemMap(1, _cancellationToken); |
|
|
|
SchedulingEngine engine = CreateEngine(repo); |
|
PlaylistEnumerator buildOne = await CreateShuffledPlaylistEnumerator(repo, itemMap, _cancellationToken); |
|
|
|
int seedAtStart = buildOne.State.Seed; |
|
|
|
DateTimeOffset currentTime = Start; |
|
var history = new List<PlayoutHistory>(); |
|
var cycles = 0; |
|
var played = 0; |
|
|
|
// stop part way into the third cycle |
|
while (cycles < 2 || played < 5) |
|
{ |
|
history.Clear(); |
|
history.AddRange(ScriptedHistoryFor(engine, buildOne, HISTORY_KEY, currentTime)); |
|
buildOne.MoveNext(currentTime); |
|
currentTime += ItemDuration; |
|
|
|
if (cycles >= 2) |
|
{ |
|
played++; |
|
} |
|
else if (buildOne.State.Index == 0) |
|
{ |
|
cycles++; |
|
} |
|
} |
|
|
|
buildOne.State.Seed.ShouldNotBe(seedAtStart, "the playlist should have reshuffled"); |
|
|
|
List<int> expected = Take(buildOne, 10); |
|
|
|
PlaylistEnumerator buildTwo = await CreateShuffledPlaylistEnumerator(repo, itemMap, _cancellationToken); |
|
ApplyScriptedHistory(engine, history, currentTime, HISTORY_KEY, itemMap, buildTwo); |
|
|
|
Take(buildTwo, 10).ShouldBe(expected); |
|
} |
|
|
|
private static async Task<PlaylistEnumerator> CreateShuffledPlaylistEnumerator( |
|
IMediaCollectionRepository repo, |
|
Dictionary<PlaylistItem, List<MediaItem>> itemMap, |
|
CancellationToken cancellationToken) => |
|
await PlaylistEnumerator.Create( |
|
repo, |
|
itemMap, |
|
new CollectionEnumeratorState { Seed = PlayoutSeed, Index = 0 }, |
|
shufflePlaylistItems: true, |
|
batchSize: Option<int>.None, |
|
randomStartPoint: false, |
|
cancellationToken); |
|
|
|
private static IMediaCollectionRepository FakeUnevenPlaylistRepository(PlaybackOrder itemPlaybackOrder) |
|
{ |
|
int[] sizes = [2, 3, 2, 4]; |
|
|
|
Dictionary<PlaylistItem, List<MediaItem>> itemMap = Enumerable.Range(1, sizes.Length) |
|
.ToDictionary( |
|
collectionId => new PlaylistItem |
|
{ |
|
Id = collectionId, |
|
Index = collectionId - 1, |
|
PlaybackOrder = itemPlaybackOrder, |
|
PlayAll = false, |
|
CollectionType = CollectionType.Collection, |
|
CollectionId = collectionId, |
|
IncludeInProgramGuide = true |
|
}, |
|
collectionId => Enumerable.Range(0, sizes[collectionId - 1]) |
|
.Map(i => (MediaItem)FakeMovie(collectionId * 100 + i)) |
|
.ToList()); |
|
|
|
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>(); |
|
repo.GetPlaylistItemMap(1, Arg.Any<CancellationToken>()).Returns(_ => Task.FromResult(itemMap)); |
|
|
|
return repo; |
|
} |
|
|
|
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(); |
|
} |
|
}
|
|
|