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.
1139 lines
46 KiB
1139 lines
46 KiB
using System.Reflection; |
|
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.Extensions; |
|
using ErsatzTV.Core.Interfaces.Metadata; |
|
using ErsatzTV.Core.Interfaces.Repositories; |
|
using ErsatzTV.Core.Interfaces.Scheduling; |
|
using Microsoft.Extensions.Logging; |
|
using Newtonsoft.Json; |
|
using Map = LanguageExt.Map; |
|
|
|
namespace ErsatzTV.Core.Scheduling; |
|
|
|
// TODO: these tests fail on days when offset changes |
|
// because the change happens during the playout |
|
public class PlayoutBuilder : IPlayoutBuilder |
|
{ |
|
private static readonly Random Random = new(); |
|
private readonly IArtistRepository _artistRepository; |
|
private readonly IConfigElementRepository _configElementRepository; |
|
private readonly ILocalFileSystem _localFileSystem; |
|
private readonly ILogger<PlayoutBuilder> _logger; |
|
private readonly IMediaCollectionRepository _mediaCollectionRepository; |
|
private readonly IMultiEpisodeShuffleCollectionEnumeratorFactory _multiEpisodeFactory; |
|
private readonly ITelevisionRepository _televisionRepository; |
|
|
|
public PlayoutBuilder( |
|
IConfigElementRepository configElementRepository, |
|
IMediaCollectionRepository mediaCollectionRepository, |
|
ITelevisionRepository televisionRepository, |
|
IArtistRepository artistRepository, |
|
IMultiEpisodeShuffleCollectionEnumeratorFactory multiEpisodeFactory, |
|
ILocalFileSystem localFileSystem, |
|
ILogger<PlayoutBuilder> logger) |
|
{ |
|
_configElementRepository = configElementRepository; |
|
_mediaCollectionRepository = mediaCollectionRepository; |
|
_televisionRepository = televisionRepository; |
|
_artistRepository = artistRepository; |
|
_multiEpisodeFactory = multiEpisodeFactory; |
|
_localFileSystem = localFileSystem; |
|
_logger = logger; |
|
} |
|
|
|
public async Task<Playout> Build(Playout playout, PlayoutBuildMode mode, CancellationToken cancellationToken) |
|
{ |
|
if (playout.ProgramSchedulePlayoutType is not ProgramSchedulePlayoutType.Flood) |
|
{ |
|
_logger.LogWarning( |
|
"Skipping playout build with type {Type} on channel {Number} - {Name}", |
|
playout.ProgramSchedulePlayoutType, |
|
playout.Channel.Number, |
|
playout.Channel.Name); |
|
|
|
return playout; |
|
} |
|
|
|
foreach (PlayoutParameters parameters in await Validate(playout)) |
|
{ |
|
// for testing purposes |
|
// if (mode == PlayoutBuildMode.Reset) |
|
// { |
|
// return await Build(playout, mode, parameters with { Start = parameters.Start.AddDays(-2) }); |
|
// } |
|
|
|
return await Build(playout, mode, parameters, cancellationToken); |
|
} |
|
|
|
return playout; |
|
} |
|
|
|
private Task<Playout> Build( |
|
Playout playout, |
|
PlayoutBuildMode mode, |
|
PlayoutParameters parameters, |
|
CancellationToken cancellationToken) => |
|
mode switch |
|
{ |
|
PlayoutBuildMode.Refresh => RefreshPlayout(playout, parameters, cancellationToken), |
|
PlayoutBuildMode.Reset => ResetPlayout(playout, parameters, cancellationToken), |
|
_ => ContinuePlayout(playout, parameters, cancellationToken) |
|
}; |
|
|
|
internal async Task<Playout> Build( |
|
Playout playout, |
|
PlayoutBuildMode mode, |
|
DateTimeOffset start, |
|
DateTimeOffset finish, |
|
CancellationToken cancellationToken) |
|
{ |
|
foreach (PlayoutParameters parameters in await Validate(playout)) |
|
{ |
|
return await Build(playout, mode, parameters with { Start = start, Finish = finish }, cancellationToken); |
|
} |
|
|
|
return playout; |
|
} |
|
|
|
private async Task<Playout> RefreshPlayout( |
|
Playout playout, |
|
PlayoutParameters parameters, |
|
CancellationToken cancellationToken) |
|
{ |
|
_logger.LogDebug( |
|
"Refreshing playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", |
|
playout.Id, |
|
playout.Channel.Number, |
|
playout.Channel.Name); |
|
|
|
playout.Items.Clear(); |
|
playout.Anchor = null; |
|
|
|
// foreach (PlayoutProgramScheduleAnchor anchor in playout.ProgramScheduleAnchors) |
|
// { |
|
// anchor.Playout = null; |
|
// anchor.Collection = null; |
|
// anchor.ProgramSchedule = null; |
|
// } |
|
|
|
// _logger.LogDebug("All anchors: {@Anchors}", playout.ProgramScheduleAnchors); |
|
|
|
// remove null anchor date ("continue" anchors) |
|
playout.ProgramScheduleAnchors.RemoveAll(a => a.AnchorDate is null); |
|
|
|
// _logger.LogDebug("Checkpoint anchors: {@Anchors}", playout.ProgramScheduleAnchors); |
|
|
|
// remove old checkpoints |
|
playout.ProgramScheduleAnchors.RemoveAll( |
|
a => a.AnchorDateOffset.IfNone(SystemTime.MaxValueUtc) < parameters.Start.Date); |
|
|
|
// remove new checkpoints |
|
playout.ProgramScheduleAnchors.RemoveAll( |
|
a => a.AnchorDateOffset.IfNone(SystemTime.MinValueUtc).Date > parameters.Start.Date); |
|
|
|
// _logger.LogDebug("Remaining anchors: {@Anchors}", playout.ProgramScheduleAnchors); |
|
|
|
var allAnchors = playout.ProgramScheduleAnchors.ToList(); |
|
|
|
var collectionIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.CollectionId)).Somes().ToHashSet(); |
|
|
|
var multiCollectionIds = |
|
playout.ProgramScheduleAnchors.Map(a => Optional(a.MultiCollectionId)).Somes().ToHashSet(); |
|
|
|
var smartCollectionIds = |
|
playout.ProgramScheduleAnchors.Map(a => Optional(a.SmartCollectionId)).Somes().ToHashSet(); |
|
|
|
var mediaItemIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.MediaItemId)).Somes().ToHashSet(); |
|
|
|
playout.ProgramScheduleAnchors.Clear(); |
|
|
|
foreach (int collectionId in collectionIds) |
|
{ |
|
PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.CollectionId == collectionId) |
|
.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); |
|
playout.ProgramScheduleAnchors.Add(minAnchor); |
|
} |
|
|
|
foreach (int multiCollectionId in multiCollectionIds) |
|
{ |
|
PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.MultiCollectionId == multiCollectionId) |
|
.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); |
|
playout.ProgramScheduleAnchors.Add(minAnchor); |
|
} |
|
|
|
foreach (int smartCollectionId in smartCollectionIds) |
|
{ |
|
PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.SmartCollectionId == smartCollectionId) |
|
.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); |
|
playout.ProgramScheduleAnchors.Add(minAnchor); |
|
} |
|
|
|
foreach (int mediaItemId in mediaItemIds) |
|
{ |
|
PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.MediaItemId == mediaItemId) |
|
.MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); |
|
playout.ProgramScheduleAnchors.Add(minAnchor); |
|
} |
|
|
|
// _logger.LogDebug("Oldest anchors for each collection: {@Anchors}", playout.ProgramScheduleAnchors); |
|
|
|
// convert checkpoints to non-checkpoints |
|
// foreach (PlayoutProgramScheduleAnchor anchor in playout.ProgramScheduleAnchors) |
|
// { |
|
// anchor.AnchorDate = null; |
|
// } |
|
|
|
// _logger.LogDebug("Final anchors: {@Anchors}", playout.ProgramScheduleAnchors); |
|
|
|
Option<DateTime> maybeAnchorDate = playout.ProgramScheduleAnchors |
|
.Map(a => Optional(a.AnchorDate)) |
|
.Somes() |
|
.HeadOrNone(); |
|
|
|
foreach (DateTime anchorDate in maybeAnchorDate) |
|
{ |
|
playout.Anchor = new PlayoutAnchor |
|
{ |
|
NextStart = anchorDate |
|
}; |
|
} |
|
|
|
return await BuildPlayoutItems( |
|
playout, |
|
parameters.Start, |
|
parameters.Finish, |
|
parameters.CollectionMediaItems, |
|
false, |
|
cancellationToken); |
|
} |
|
|
|
private async Task<Playout> ResetPlayout( |
|
Playout playout, |
|
PlayoutParameters parameters, |
|
CancellationToken cancellationToken) |
|
{ |
|
_logger.LogDebug( |
|
"Resetting playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", |
|
playout.Id, |
|
playout.Channel.Number, |
|
playout.Channel.Name); |
|
|
|
playout.Items.Clear(); |
|
playout.Anchor = null; |
|
playout.ProgramScheduleAnchors.Clear(); |
|
|
|
await BuildPlayoutItems( |
|
playout, |
|
parameters.Start, |
|
parameters.Finish, |
|
parameters.CollectionMediaItems, |
|
true, |
|
cancellationToken); |
|
|
|
return playout; |
|
} |
|
|
|
private async Task<Playout> ContinuePlayout( |
|
Playout playout, |
|
PlayoutParameters parameters, |
|
CancellationToken cancellationToken) |
|
{ |
|
_logger.LogDebug( |
|
"Building playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", |
|
playout.Id, |
|
playout.Channel.Number, |
|
playout.Channel.Name); |
|
|
|
// remove old checkpoints |
|
playout.ProgramScheduleAnchors.RemoveAll( |
|
a => a.AnchorDateOffset.IfNone(SystemTime.MaxValueUtc) < parameters.Start.Date); |
|
|
|
// _logger.LogDebug("Remaining anchors: {@Anchors}", playout.ProgramScheduleAnchors); |
|
|
|
await BuildPlayoutItems( |
|
playout, |
|
parameters.Start, |
|
parameters.Finish, |
|
parameters.CollectionMediaItems, |
|
false, |
|
cancellationToken); |
|
|
|
return playout; |
|
} |
|
|
|
private async Task<Option<PlayoutParameters>> Validate(Playout playout) |
|
{ |
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(playout); |
|
if (collectionMediaItems.IsEmpty) |
|
{ |
|
_logger.LogWarning("Playout {Playout} has no items", playout.Channel.Name); |
|
return None; |
|
} |
|
|
|
Option<bool> skipMissingItems = |
|
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems); |
|
|
|
Option<CollectionKey> maybeEmptyCollection = await CheckForEmptyCollections( |
|
collectionMediaItems, |
|
await skipMissingItems.IfNoneAsync(false)); |
|
|
|
foreach (CollectionKey emptyCollection in maybeEmptyCollection) |
|
{ |
|
Option<string> maybeName = await _mediaCollectionRepository.GetNameFromKey(emptyCollection); |
|
if (maybeName.IsSome) |
|
{ |
|
foreach (string name in maybeName) |
|
{ |
|
_logger.LogError( |
|
"Unable to rebuild playout; {CollectionType} {CollectionName} has no valid items!", |
|
emptyCollection.CollectionType, |
|
name); |
|
} |
|
} |
|
else |
|
{ |
|
_logger.LogError( |
|
"Unable to rebuild playout; collection {@CollectionKey} has no valid items!", |
|
emptyCollection); |
|
} |
|
|
|
return None; |
|
} |
|
|
|
playout.Items ??= new List<PlayoutItem>(); |
|
playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>(); |
|
|
|
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild); |
|
|
|
DateTimeOffset now = DateTimeOffset.Now; |
|
|
|
return new PlayoutParameters( |
|
now, |
|
now.AddDays(await daysToBuild.IfNoneAsync(2)), |
|
collectionMediaItems); |
|
} |
|
|
|
private async Task<Playout> BuildPlayoutItems( |
|
Playout playout, |
|
DateTimeOffset playoutStart, |
|
DateTimeOffset playoutFinish, |
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems, |
|
bool randomStartPoint, |
|
CancellationToken cancellationToken) |
|
{ |
|
DateTimeOffset trimBefore = playoutStart.AddHours(-4); |
|
DateTimeOffset trimAfter = playoutFinish; |
|
|
|
DateTimeOffset start = playoutStart; |
|
DateTimeOffset finish = playoutStart.Date.AddDays(1); |
|
|
|
// _logger.LogDebug( |
|
// "Trim before: {TrimBefore}, Start: {Start}, Finish: {Finish}, PlayoutFinish: {PlayoutFinish}", |
|
// trimBefore, |
|
// start, |
|
// finish, |
|
// playoutFinish); |
|
|
|
// build each day with "continue" anchors |
|
while (finish < playoutFinish) |
|
{ |
|
_logger.LogDebug("Building playout from {Start} to {Finish}", start, finish); |
|
playout = await BuildPlayoutItems( |
|
playout, |
|
start, |
|
finish, |
|
collectionMediaItems, |
|
true, |
|
randomStartPoint, |
|
cancellationToken); |
|
|
|
// only randomize once (at the start of the playout) |
|
randomStartPoint = false; |
|
|
|
start = playout.Anchor.NextStartOffset; |
|
finish = finish.AddDays(1); |
|
} |
|
|
|
if (start < playoutFinish) |
|
{ |
|
// build one final time without continue anchors |
|
_logger.LogDebug("Building final playout from {Start} to {Finish}", start, playoutFinish); |
|
playout = await BuildPlayoutItems( |
|
playout, |
|
start, |
|
playoutFinish, |
|
collectionMediaItems, |
|
false, |
|
randomStartPoint, |
|
cancellationToken); |
|
} |
|
|
|
// remove old items |
|
playout.Items.RemoveAll(old => old.FinishOffset < trimBefore); |
|
|
|
// check for future items that aren't grouped inside range |
|
var futureItems = playout.Items.Filter(i => i.StartOffset > trimAfter).ToList(); |
|
foreach (PlayoutItem futureItem in futureItems) |
|
{ |
|
if (playout.Items.All(i => i == futureItem || i.GuideGroup != futureItem.GuideGroup)) |
|
{ |
|
_logger.LogError( |
|
"Playout item scheduled for {Time} after hard stop of {HardStop}", |
|
futureItem.StartOffset, |
|
trimAfter); |
|
|
|
// it feels hacky to have to clean up a playlist like this, |
|
// so only log the error, and leave the bad data to fail tests |
|
// playout.Items.Remove(futureItem); |
|
} |
|
} |
|
|
|
return playout; |
|
} |
|
|
|
private async Task<Playout> BuildPlayoutItems( |
|
Playout playout, |
|
DateTimeOffset playoutStart, |
|
DateTimeOffset playoutFinish, |
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems, |
|
bool saveAnchorDate, |
|
bool randomStartPoint, |
|
CancellationToken cancellationToken) |
|
{ |
|
ProgramSchedule activeSchedule = PlayoutScheduleSelector.GetProgramScheduleFor( |
|
playout.ProgramSchedule, |
|
playout.ProgramScheduleAlternates, |
|
playoutStart); |
|
|
|
// random start points are disabled in some scenarios, so ensure it's enabled and active |
|
randomStartPoint = randomStartPoint && activeSchedule.RandomStartPoint; |
|
|
|
var sortedScheduleItems = activeSchedule.Items.OrderBy(i => i.Index).ToList(); |
|
CollectionEnumeratorState scheduleItemsEnumeratorState = |
|
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState |
|
{ Seed = Random.Next(), Index = 0 }; |
|
IScheduleItemsEnumerator scheduleItemsEnumerator = activeSchedule.ShuffleScheduleItems |
|
? new ShuffledScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState) |
|
: new OrderedScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState); |
|
|
|
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>(); |
|
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems) |
|
{ |
|
// use configured playback order for primary collection, shuffle for filler |
|
Option<ProgramScheduleItem> maybeScheduleItem = sortedScheduleItems |
|
.FirstOrDefault(item => CollectionKey.ForScheduleItem(item) == collectionKey); |
|
PlaybackOrder playbackOrder = maybeScheduleItem |
|
.Match(item => item.PlaybackOrder, () => PlaybackOrder.Shuffle); |
|
IMediaCollectionEnumerator enumerator = |
|
await GetMediaCollectionEnumerator( |
|
playout, |
|
activeSchedule, |
|
collectionKey, |
|
mediaItems, |
|
playbackOrder, |
|
randomStartPoint, |
|
cancellationToken); |
|
collectionEnumerators.Add(collectionKey, enumerator); |
|
} |
|
|
|
var collectionItemCount = collectionMediaItems.Map((k, v) => (k, v.Count)).Values.ToDictionary(); |
|
|
|
var scheduleItemsFillGroupEnumerators = new Dictionary<int, IScheduleItemsEnumerator>(); |
|
foreach (ProgramScheduleItem scheduleItem in sortedScheduleItems.Where( |
|
si => si.FillWithGroupMode is not FillWithGroupMode.None)) |
|
{ |
|
var collectionKey = CollectionKey.ForScheduleItem(scheduleItem); |
|
List<MediaItem> mediaItems = await MediaItemsForCollection.Collect( |
|
_mediaCollectionRepository, |
|
_televisionRepository, |
|
_artistRepository, |
|
collectionKey); |
|
string collectionKeyString = JsonConvert.SerializeObject( |
|
collectionKey, |
|
Formatting.None, |
|
new JsonSerializerSettings |
|
{ |
|
NullValueHandling = NullValueHandling.Ignore |
|
}); |
|
collectionKeyString = $"{scheduleItem.Id}:{collectionKeyString}"; |
|
var fakeCollections = _mediaCollectionRepository.GroupIntoFakeCollections(mediaItems, collectionKeyString) |
|
.Filter(c => c.ShowId > 0 || c.ArtistId > 0 || !string.IsNullOrWhiteSpace(c.Key)) |
|
.ToList(); |
|
List<ProgramScheduleItem> fakeScheduleItems = []; |
|
|
|
// this will be used to clone a schedule item |
|
MethodInfo generic = typeof(JsonConvert).GetMethods() |
|
.FirstOrDefault( |
|
x => x.Name.Equals("DeserializeObject", StringComparison.OrdinalIgnoreCase) && x.IsGenericMethod && |
|
x.GetParameters().Length == 1)?.MakeGenericMethod(scheduleItem.GetType()); |
|
|
|
foreach (CollectionWithItems fakeCollection in fakeCollections) |
|
{ |
|
CollectionKey key = (fakeCollection.ShowId, fakeCollection.ArtistId, fakeCollection.Key) switch |
|
{ |
|
var (showId, _, _) when showId > 0 => new CollectionKey |
|
{ |
|
CollectionType = ProgramScheduleItemCollectionType.TelevisionShow, |
|
MediaItemId = showId, |
|
FakeCollectionKey = collectionKeyString |
|
}, |
|
var (_, artistId, _) when artistId > 0 => new CollectionKey |
|
{ |
|
CollectionType = ProgramScheduleItemCollectionType.Artist, |
|
MediaItemId = artistId, |
|
FakeCollectionKey = collectionKeyString |
|
}, |
|
var (_, _, k) when k is not null => new CollectionKey |
|
{ |
|
CollectionType = ProgramScheduleItemCollectionType.FakeCollection, |
|
FakeCollectionKey = collectionKeyString |
|
}, |
|
var (_, _, _) => null |
|
}; |
|
|
|
if (key is null) |
|
{ |
|
continue; |
|
} |
|
|
|
string serialized = JsonConvert.SerializeObject(scheduleItem); |
|
var copyScheduleItem = generic.Invoke(this, [serialized]) as ProgramScheduleItem; |
|
copyScheduleItem.CollectionType = key.CollectionType; |
|
copyScheduleItem.MediaItemId = key.MediaItemId; |
|
copyScheduleItem.FakeCollectionKey = key.FakeCollectionKey; |
|
fakeScheduleItems.Add(copyScheduleItem); |
|
|
|
IMediaCollectionEnumerator enumerator = await GetMediaCollectionEnumerator( |
|
playout, |
|
activeSchedule, |
|
key, |
|
fakeCollection.MediaItems, |
|
scheduleItem.PlaybackOrder, |
|
randomStartPoint, |
|
cancellationToken); |
|
|
|
collectionEnumerators.Add(key, enumerator); |
|
|
|
// this makes multiple (0) work - since it needs the number of items in the collection |
|
collectionItemCount.Add(key, fakeCollection.MediaItems.Count); |
|
} |
|
|
|
CollectionEnumeratorState enumeratorState = |
|
playout.FillGroupIndices.Any(fgi => fgi.ProgramScheduleItemId == scheduleItem.Id) |
|
? playout.FillGroupIndices.Find(fgi => fgi.ProgramScheduleItemId == scheduleItem.Id).EnumeratorState |
|
: new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }; |
|
|
|
switch (scheduleItem.FillWithGroupMode) |
|
{ |
|
case FillWithGroupMode.FillWithOrderedGroups: |
|
{ |
|
var enumerator = new OrderedScheduleItemsEnumerator(fakeScheduleItems, enumeratorState); |
|
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator; |
|
break; |
|
} |
|
case FillWithGroupMode.FillWithShuffledGroups: |
|
{ |
|
var enumerator = new ShuffledScheduleItemsEnumerator(fakeScheduleItems, enumeratorState); |
|
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator; |
|
break; |
|
} |
|
} |
|
} |
|
|
|
// find start anchor |
|
PlayoutAnchor startAnchor = FindStartAnchor(playout, playoutStart, scheduleItemsEnumerator); |
|
// _logger.LogDebug("Start anchor: {@StartAnchor}", startAnchor); |
|
|
|
// start at the previously-decided time |
|
DateTimeOffset currentTime = startAnchor.NextStartOffset.ToLocalTime(); |
|
|
|
if (currentTime >= playoutFinish) |
|
{ |
|
// nothing to do, no need to add more anchors |
|
return playout; |
|
} |
|
|
|
// _logger.LogDebug( |
|
// "Starting playout ({PlayoutId}) for channel {ChannelNumber} - {ChannelName} at {StartTime}", |
|
// playout.Id, |
|
// playout.Channel.Number, |
|
// playout.Channel.Name, |
|
// currentTime); |
|
|
|
// removing any items scheduled past the start anchor |
|
// this could happen if the app was closed after scheduling items |
|
// but before saving the anchor |
|
int removed = playout.Items.RemoveAll(pi => pi.StartOffset >= currentTime); |
|
if (removed > 0) |
|
{ |
|
_logger.LogWarning("Removed {Count} schedule items beyond current start anchor", removed); |
|
} |
|
|
|
// start with the previously-decided schedule item |
|
// start with the previous multiple/duration states |
|
var playoutBuilderState = new PlayoutBuilderState( |
|
scheduleItemsEnumerator, |
|
Optional(startAnchor.MultipleRemaining), |
|
startAnchor.DurationFinishOffset, |
|
startAnchor.InFlood, |
|
startAnchor.InDurationFiller, |
|
startAnchor.NextGuideGroup, |
|
currentTime); |
|
|
|
var schedulerOne = new PlayoutModeSchedulerOne(_logger); |
|
var schedulerMultiple = new PlayoutModeSchedulerMultiple(collectionItemCount.ToMap(), _logger); |
|
var schedulerDuration = new PlayoutModeSchedulerDuration(_logger); |
|
var schedulerFlood = new PlayoutModeSchedulerFlood(_logger); |
|
|
|
var timeCount = new Dictionary<DateTimeOffset, int>(); |
|
|
|
// loop until we're done filling the desired amount of time |
|
while (playoutBuilderState.CurrentTime < playoutFinish && !cancellationToken.IsCancellationRequested) |
|
{ |
|
if (timeCount.TryGetValue(playoutBuilderState.CurrentTime, out int count)) |
|
{ |
|
timeCount[playoutBuilderState.CurrentTime] = count + 1; |
|
} |
|
else |
|
{ |
|
timeCount[playoutBuilderState.CurrentTime] = 1; |
|
} |
|
|
|
if (timeCount[playoutBuilderState.CurrentTime] == 6) |
|
{ |
|
_logger.LogWarning( |
|
"Failed to schedule beyond {Time}; aborting playout build - this is a bug", |
|
playoutBuilderState.CurrentTime); |
|
|
|
throw new InvalidOperationException("Scheduling loop encountered"); |
|
} |
|
|
|
// _logger.LogDebug("Playout time is {CurrentTime}", playoutBuilderState.CurrentTime); |
|
|
|
// get the schedule item out of the sorted list |
|
ProgramScheduleItem scheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Current; |
|
|
|
// replace with the fake schedule item when filling with group |
|
if (scheduleItem.FillWithGroupMode is not FillWithGroupMode.None) |
|
{ |
|
scheduleItem = scheduleItemsFillGroupEnumerators[scheduleItem.Id].Current; |
|
} |
|
|
|
ProgramScheduleItem nextScheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Peek(1); |
|
|
|
Tuple<PlayoutBuilderState, List<PlayoutItem>> result = scheduleItem switch |
|
{ |
|
ProgramScheduleItemMultiple multiple => schedulerMultiple.Schedule( |
|
playoutBuilderState, |
|
collectionEnumerators, |
|
multiple, |
|
nextScheduleItem, |
|
playoutFinish, |
|
cancellationToken), |
|
ProgramScheduleItemDuration duration => schedulerDuration.Schedule( |
|
playoutBuilderState, |
|
collectionEnumerators, |
|
duration, |
|
nextScheduleItem, |
|
playoutFinish, |
|
cancellationToken), |
|
ProgramScheduleItemFlood flood => schedulerFlood.Schedule( |
|
playoutBuilderState, |
|
collectionEnumerators, |
|
flood, |
|
nextScheduleItem, |
|
playoutFinish, |
|
cancellationToken), |
|
ProgramScheduleItemOne one => schedulerOne.Schedule( |
|
playoutBuilderState, |
|
collectionEnumerators, |
|
one, |
|
nextScheduleItem, |
|
playoutFinish, |
|
cancellationToken), |
|
_ => throw new NotSupportedException(nameof(scheduleItem)) |
|
}; |
|
|
|
(PlayoutBuilderState nextState, List<PlayoutItem> playoutItems) = result; |
|
|
|
// if we completed a multiple/duration block, move to the next fill group |
|
if (scheduleItem.FillWithGroupMode is not FillWithGroupMode.None) |
|
{ |
|
if (nextState.MultipleRemaining.IsNone && nextState.DurationFinish.IsNone) |
|
{ |
|
scheduleItemsFillGroupEnumerators[scheduleItem.Id].MoveNext(); |
|
} |
|
} |
|
|
|
foreach (PlayoutItem playoutItem in playoutItems) |
|
{ |
|
playout.Items.Add(playoutItem); |
|
} |
|
|
|
playoutBuilderState = nextState; |
|
} |
|
|
|
// once more to get playout anchor |
|
ProgramScheduleItem anchorScheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Current; |
|
|
|
if (playout.Items.Count != 0) |
|
{ |
|
DateTimeOffset maxStartTime = playout.Items.Max(i => i.FinishOffset); |
|
if (maxStartTime < playoutBuilderState.CurrentTime) |
|
{ |
|
playoutBuilderState = playoutBuilderState with { CurrentTime = maxStartTime }; |
|
} |
|
} |
|
|
|
playout.Anchor = new PlayoutAnchor |
|
{ |
|
ScheduleItemsEnumeratorState = playoutBuilderState.ScheduleItemsEnumerator.State, |
|
NextStart = PlayoutModeSchedulerBase<ProgramScheduleItem> |
|
.GetStartTimeAfter(playoutBuilderState, anchorScheduleItem) |
|
.UtcDateTime, |
|
InFlood = playoutBuilderState.InFlood, |
|
InDurationFiller = playoutBuilderState.InDurationFiller, |
|
NextGuideGroup = playoutBuilderState.NextGuideGroup |
|
}; |
|
|
|
foreach (int multipleRemaining in playoutBuilderState.MultipleRemaining) |
|
{ |
|
playout.Anchor.MultipleRemaining = multipleRemaining; |
|
} |
|
|
|
foreach (DateTimeOffset durationFinish in playoutBuilderState.DurationFinish) |
|
{ |
|
playout.Anchor.DurationFinish = durationFinish.UtcDateTime; |
|
} |
|
|
|
// build program schedule anchors |
|
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators, saveAnchorDate); |
|
|
|
// build fill group indices |
|
playout.FillGroupIndices = BuildFillGroupIndices(playout, scheduleItemsFillGroupEnumerators); |
|
|
|
return playout; |
|
} |
|
|
|
private static List<PlayoutScheduleItemFillGroupIndex> BuildFillGroupIndices( |
|
Playout playout, |
|
Dictionary<int, IScheduleItemsEnumerator> scheduleItemsFillGroupEnumerators) |
|
{ |
|
var result = playout.FillGroupIndices.ToList(); |
|
|
|
foreach ((int programScheduleItemId, IScheduleItemsEnumerator enumerator) in scheduleItemsFillGroupEnumerators) |
|
{ |
|
Option<PlayoutScheduleItemFillGroupIndex> maybeFgi = Optional( |
|
result.FirstOrDefault(fgi => fgi.ProgramScheduleItemId == programScheduleItemId)); |
|
|
|
foreach (PlayoutScheduleItemFillGroupIndex fgi in maybeFgi) |
|
{ |
|
fgi.EnumeratorState = enumerator.State; |
|
} |
|
|
|
if (maybeFgi.IsNone) |
|
{ |
|
var fgi = new PlayoutScheduleItemFillGroupIndex |
|
{ |
|
PlayoutId = playout.Id, |
|
ProgramScheduleItemId = programScheduleItemId, |
|
EnumeratorState = enumerator.State |
|
}; |
|
|
|
result.Add(fgi); |
|
} |
|
} |
|
|
|
return result; |
|
} |
|
|
|
private async Task<Map<CollectionKey, List<MediaItem>>> GetCollectionMediaItems(Playout playout) |
|
{ |
|
var collectionKeys = playout.ProgramSchedule.Items |
|
.Append(playout.ProgramScheduleAlternates.Bind(psa => psa.ProgramSchedule.Items)) |
|
.DistinctBy(i => i.Id) |
|
.SelectMany(CollectionKeysForItem) |
|
.Distinct() |
|
.ToList(); |
|
|
|
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> tuples = await collectionKeys.Map( |
|
async collectionKey => Tuple( |
|
collectionKey, |
|
await MediaItemsForCollection.Collect( |
|
_mediaCollectionRepository, |
|
_televisionRepository, |
|
_artistRepository, |
|
collectionKey))).SequenceParallel(); |
|
|
|
return Map.createRange(tuples); |
|
} |
|
|
|
private async Task<Option<CollectionKey>> CheckForEmptyCollections( |
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems, |
|
bool skipMissingItems) |
|
{ |
|
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems) |
|
{ |
|
var zeroItems = new List<MediaItem>(); |
|
var missingItems = new List<MediaItem>(); |
|
|
|
foreach (MediaItem item in items) |
|
{ |
|
bool isZero = item switch |
|
{ |
|
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone() |
|
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, |
|
Episode e => await e.MediaVersions.Map(v => v.Duration).HeadOrNone() |
|
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, |
|
MusicVideo mv => await mv.MediaVersions.Map(v => v.Duration).HeadOrNone() |
|
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, |
|
OtherVideo ov => await ov.MediaVersions.Map(v => v.Duration).HeadOrNone() |
|
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, |
|
Song s => await s.MediaVersions.Map(v => v.Duration).HeadOrNone() |
|
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, |
|
_ => true |
|
}; |
|
|
|
if (skipMissingItems && item.State is MediaItemState.FileNotFound or MediaItemState.Unavailable) |
|
{ |
|
_logger.LogWarning( |
|
"Skipping media item that does not exist on disk {MediaItem} - {MediaItemTitle} - {Path}", |
|
item.Id, |
|
DisplayTitle(item), |
|
item.GetHeadVersion().MediaFiles.Head().Path); |
|
|
|
missingItems.Add(item); |
|
} |
|
else if (isZero) |
|
{ |
|
_logger.LogWarning( |
|
"Skipping media item with zero duration {MediaItem} - {MediaItemTitle}", |
|
item.Id, |
|
DisplayTitle(item)); |
|
|
|
zeroItems.Add(item); |
|
} |
|
} |
|
|
|
items.RemoveAll(missingItems.Contains); |
|
items.RemoveAll(zeroItems.Contains); |
|
} |
|
|
|
return collectionMediaItems.Find(c => c.Value.Count == 0).Map(c => c.Key); |
|
} |
|
|
|
private static PlayoutAnchor FindStartAnchor( |
|
Playout playout, |
|
DateTimeOffset start, |
|
IScheduleItemsEnumerator enumerator) => |
|
Optional(playout.Anchor).IfNone( |
|
() => |
|
{ |
|
ProgramScheduleItem schedule = enumerator.Current; |
|
switch (schedule.StartType) |
|
{ |
|
case StartType.Fixed: |
|
return new PlayoutAnchor |
|
{ |
|
ScheduleItemsEnumeratorState = enumerator.State, |
|
NextStart = (start - start.TimeOfDay).UtcDateTime + |
|
schedule.StartTime.GetValueOrDefault() |
|
}; |
|
case StartType.Dynamic: |
|
default: |
|
return new PlayoutAnchor |
|
{ |
|
ScheduleItemsEnumeratorState = enumerator.State, |
|
NextStart = (start - start.TimeOfDay).UtcDateTime |
|
}; |
|
} |
|
}); |
|
|
|
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors( |
|
Playout playout, |
|
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators, |
|
bool saveAnchorDate) |
|
{ |
|
var result = new List<PlayoutProgramScheduleAnchor>(); |
|
|
|
foreach (CollectionKey collectionKey in collectionEnumerators.Keys) |
|
{ |
|
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault( |
|
a => a.CollectionType == collectionKey.CollectionType |
|
&& a.CollectionId == collectionKey.CollectionId |
|
&& a.MediaItemId == collectionKey.MediaItemId |
|
&& a.FakeCollectionKey == collectionKey.FakeCollectionKey |
|
&& a.SmartCollectionId == collectionKey.SmartCollectionId |
|
&& a.MultiCollectionId == collectionKey.MultiCollectionId |
|
&& a.AnchorDate is null); |
|
|
|
var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State); |
|
|
|
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match( |
|
existing => |
|
{ |
|
existing.EnumeratorState = maybeEnumeratorState[collectionKey]; |
|
return existing; |
|
}, |
|
() => new PlayoutProgramScheduleAnchor |
|
{ |
|
Playout = playout, |
|
PlayoutId = playout.Id, |
|
CollectionType = collectionKey.CollectionType, |
|
CollectionId = collectionKey.CollectionId, |
|
MultiCollectionId = collectionKey.MultiCollectionId, |
|
SmartCollectionId = collectionKey.SmartCollectionId, |
|
MediaItemId = collectionKey.MediaItemId, |
|
FakeCollectionKey = collectionKey.FakeCollectionKey, |
|
EnumeratorState = maybeEnumeratorState[collectionKey] |
|
}); |
|
|
|
if (saveAnchorDate) |
|
{ |
|
scheduleAnchor.AnchorDate = playout.Anchor?.NextStart; |
|
} |
|
|
|
result.Add(scheduleAnchor); |
|
} |
|
|
|
foreach (PlayoutProgramScheduleAnchor checkpointAnchor in playout.ProgramScheduleAnchors.Where( |
|
a => a.AnchorDate is not null)) |
|
{ |
|
result.Add(checkpointAnchor); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator( |
|
Playout playout, |
|
ProgramSchedule activeSchedule, |
|
CollectionKey collectionKey, |
|
List<MediaItem> mediaItems, |
|
PlaybackOrder playbackOrder, |
|
bool randomStartPoint, |
|
CancellationToken cancellationToken) |
|
{ |
|
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors |
|
.OrderByDescending(a => a.AnchorDate ?? DateTime.MaxValue) |
|
.FirstOrDefault( |
|
a => a.CollectionType == collectionKey.CollectionType |
|
&& a.CollectionId == collectionKey.CollectionId |
|
&& a.MultiCollectionId == collectionKey.MultiCollectionId |
|
&& a.SmartCollectionId == collectionKey.SmartCollectionId |
|
&& a.MediaItemId == collectionKey.MediaItemId); |
|
|
|
CollectionEnumeratorState state = null; |
|
|
|
foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor) |
|
{ |
|
// _logger.LogDebug("Selecting anchor {@Anchor}", anchor); |
|
|
|
anchor.EnumeratorState ??= new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }; |
|
|
|
state = anchor.EnumeratorState; |
|
} |
|
|
|
state ??= new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }; |
|
|
|
int collectionId = collectionKey.CollectionId ?? 0; |
|
|
|
if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection && |
|
await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionId)) |
|
{ |
|
Option<Collection> maybeCollectionWithItems = |
|
await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked(collectionId); |
|
|
|
foreach (Collection collectionWithItems in maybeCollectionWithItems) |
|
{ |
|
return new CustomOrderCollectionEnumerator( |
|
collectionWithItems, |
|
mediaItems, |
|
state); |
|
} |
|
} |
|
|
|
// index shouldn't ever be greater than zero with randomStartPoint since anchors shouldn't exist, but |
|
randomStartPoint = randomStartPoint && state.Index == 0; |
|
|
|
switch (playbackOrder) |
|
{ |
|
case PlaybackOrder.Chronological: |
|
if (randomStartPoint) |
|
{ |
|
state = new CollectionEnumeratorState |
|
{ |
|
Seed = state.Seed, |
|
Index = Random.Next(0, mediaItems.Count - 1) |
|
}; |
|
} |
|
|
|
return new ChronologicalMediaCollectionEnumerator(mediaItems, state); |
|
case PlaybackOrder.SeasonEpisode: |
|
if (randomStartPoint) |
|
{ |
|
state = new CollectionEnumeratorState |
|
{ |
|
Seed = state.Seed, |
|
Index = Random.Next(0, mediaItems.Count - 1) |
|
}; |
|
} |
|
|
|
return new SeasonEpisodeMediaCollectionEnumerator(mediaItems, state); |
|
case PlaybackOrder.Random: |
|
return new RandomizedMediaCollectionEnumerator(mediaItems, state); |
|
case PlaybackOrder.ShuffleInOrder: |
|
return new ShuffleInOrderCollectionEnumerator( |
|
await GetCollectionItemsForShuffleInOrder(collectionKey), |
|
state, |
|
activeSchedule.RandomStartPoint, |
|
cancellationToken); |
|
case PlaybackOrder.MultiEpisodeShuffle when |
|
collectionKey.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow && |
|
collectionKey.MediaItemId.HasValue: |
|
foreach (Show show in await _televisionRepository.GetShow(collectionKey.MediaItemId.Value)) |
|
{ |
|
foreach (MetadataGuid guid in show.ShowMetadata.Map(sm => sm.Guids).Flatten()) |
|
{ |
|
string jsScriptPath = Path.ChangeExtension( |
|
Path.Combine( |
|
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, |
|
guid.Guid.Replace("://", "_")), |
|
"js"); |
|
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); |
|
if (_localFileSystem.FileExists(jsScriptPath)) |
|
{ |
|
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath); |
|
try |
|
{ |
|
return _multiEpisodeFactory.Create(jsScriptPath, mediaItems, state, cancellationToken); |
|
} |
|
catch (Exception ex) |
|
{ |
|
_logger.LogError( |
|
ex, |
|
"Failed to initialize multi-episode shuffle; falling back to normal shuffle"); |
|
} |
|
} |
|
} |
|
} |
|
|
|
// fall back to shuffle if show or template cannot be found |
|
goto case PlaybackOrder.Shuffle; |
|
|
|
// fall back to shuffle when television show isn't selected |
|
case PlaybackOrder.MultiEpisodeShuffle: |
|
case PlaybackOrder.Shuffle: |
|
return new ShuffledMediaCollectionEnumerator( |
|
await GetGroupedMediaItemsForShuffle(activeSchedule, mediaItems, collectionKey), |
|
state, |
|
cancellationToken); |
|
default: |
|
// TODO: handle this error case differently? |
|
return new RandomizedMediaCollectionEnumerator(mediaItems, state); |
|
} |
|
} |
|
|
|
private async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle( |
|
ProgramSchedule activeSchedule, |
|
List<MediaItem> mediaItems, |
|
CollectionKey collectionKey) |
|
{ |
|
if (collectionKey.MultiCollectionId != null) |
|
{ |
|
List<CollectionWithItems> collections = await _mediaCollectionRepository |
|
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value); |
|
|
|
return MultiCollectionGrouper.GroupMediaItems(collections); |
|
} |
|
|
|
return activeSchedule.KeepMultiPartEpisodesTogether |
|
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, activeSchedule.TreatCollectionsAsShows) |
|
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList(); |
|
} |
|
|
|
private async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(CollectionKey collectionKey) |
|
{ |
|
List<CollectionWithItems> result; |
|
|
|
if (collectionKey.MultiCollectionId != null) |
|
{ |
|
result = await _mediaCollectionRepository.GetMultiCollectionCollections( |
|
collectionKey.MultiCollectionId.Value); |
|
} |
|
else |
|
{ |
|
result = await _mediaCollectionRepository.GetFakeMultiCollectionCollections( |
|
collectionKey.CollectionId, |
|
collectionKey.SmartCollectionId); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
internal static string DisplayTitle(MediaItem mediaItem) |
|
{ |
|
switch (mediaItem) |
|
{ |
|
case Episode e: |
|
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() |
|
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty); |
|
return e.EpisodeMetadata.HeadOrNone() |
|
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{em.EpisodeNumber:00} - {em.Title}") |
|
.IfNone("[unknown episode]"); |
|
case Movie m: |
|
return m.MovieMetadata.HeadOrNone().Match(mm => mm.Title ?? string.Empty, () => "[unknown movie]"); |
|
case MusicVideo mv: |
|
string artistName = mv.Artist.ArtistMetadata.HeadOrNone() |
|
.Map(am => $"{am.Title} - ").IfNone(string.Empty); |
|
return mv.MusicVideoMetadata.HeadOrNone() |
|
.Map(mvm => $"{artistName}{mvm.Title}") |
|
.IfNone("[unknown music video]"); |
|
case OtherVideo ov: |
|
return ov.OtherVideoMetadata.HeadOrNone().Match( |
|
ovm => ovm.Title ?? string.Empty, |
|
() => "[unknown video]"); |
|
case Song s: |
|
return s.SongMetadata.HeadOrNone().Match( |
|
sm => sm.Title ?? string.Empty, |
|
() => "[unknown song]"); |
|
default: |
|
return string.Empty; |
|
} |
|
} |
|
|
|
private static List<CollectionKey> CollectionKeysForItem(ProgramScheduleItem item) |
|
{ |
|
var result = new List<CollectionKey> |
|
{ |
|
CollectionKey.ForScheduleItem(item) |
|
}; |
|
|
|
if (item.PreRollFiller != null) |
|
{ |
|
result.Add(CollectionKey.ForFillerPreset(item.PreRollFiller)); |
|
} |
|
|
|
if (item.MidRollFiller != null) |
|
{ |
|
result.Add(CollectionKey.ForFillerPreset(item.MidRollFiller)); |
|
} |
|
|
|
if (item.PostRollFiller != null) |
|
{ |
|
result.Add(CollectionKey.ForFillerPreset(item.PostRollFiller)); |
|
} |
|
|
|
if (item.TailFiller != null) |
|
{ |
|
result.Add(CollectionKey.ForFillerPreset(item.TailFiller)); |
|
} |
|
|
|
if (item.FallbackFiller != null) |
|
{ |
|
result.Add(CollectionKey.ForFillerPreset(item.FallbackFiller)); |
|
} |
|
|
|
return result; |
|
} |
|
}
|
|
|