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.
780 lines
31 KiB
780 lines
31 KiB
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.Interfaces.Repositories; |
|
using ErsatzTV.Core.Interfaces.Scheduling; |
|
using LanguageExt.UnsafeValueAccess; |
|
using Microsoft.Extensions.Logging; |
|
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 ILogger<PlayoutBuilder> _logger; |
|
private readonly IConfigElementRepository _configElementRepository; |
|
private readonly IMediaCollectionRepository _mediaCollectionRepository; |
|
private readonly ITelevisionRepository _televisionRepository; |
|
|
|
public PlayoutBuilder( |
|
IConfigElementRepository configElementRepository, |
|
IMediaCollectionRepository mediaCollectionRepository, |
|
ITelevisionRepository televisionRepository, |
|
IArtistRepository artistRepository, |
|
ILogger<PlayoutBuilder> logger) |
|
{ |
|
_configElementRepository = configElementRepository; |
|
_mediaCollectionRepository = mediaCollectionRepository; |
|
_televisionRepository = televisionRepository; |
|
_artistRepository = artistRepository; |
|
_logger = logger; |
|
} |
|
|
|
public async Task<Playout> Build(Playout playout, PlayoutBuildMode mode) |
|
{ |
|
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); |
|
} |
|
|
|
return playout; |
|
} |
|
|
|
private Task<Playout> Build(Playout playout, PlayoutBuildMode mode, PlayoutParameters parameters) => |
|
mode switch |
|
{ |
|
PlayoutBuildMode.Refresh => RefreshPlayout(playout, parameters), |
|
PlayoutBuildMode.Reset => ResetPlayout(playout, parameters), |
|
_ => ContinuePlayout(playout, parameters) |
|
}; |
|
|
|
internal async Task<Playout> Build( |
|
Playout playout, |
|
PlayoutBuildMode mode, |
|
DateTimeOffset start, |
|
DateTimeOffset finish) |
|
{ |
|
foreach (PlayoutParameters parameters in await Validate(playout)) |
|
{ |
|
return await Build(playout, mode, parameters with { Start = start, Finish = finish }); |
|
} |
|
|
|
return playout; |
|
} |
|
|
|
private async Task<Playout> RefreshPlayout(Playout playout, PlayoutParameters parameters) |
|
{ |
|
_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); |
|
} |
|
|
|
private async Task<Playout> ResetPlayout(Playout playout, PlayoutParameters parameters) |
|
{ |
|
_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); |
|
|
|
return playout; |
|
} |
|
|
|
private async Task<Playout> ContinuePlayout(Playout playout, PlayoutParameters parameters) |
|
{ |
|
_logger.LogDebug( |
|
"Building playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", |
|
playout.Id, |
|
playout.Channel.Number, |
|
playout.Channel.Name); |
|
|
|
await BuildPlayoutItems( |
|
playout, |
|
parameters.Start, |
|
parameters.Finish, |
|
parameters.CollectionMediaItems); |
|
|
|
return playout; |
|
} |
|
|
|
private async Task<Option<PlayoutParameters>> Validate(Playout playout) |
|
{ |
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(playout); |
|
if (!collectionMediaItems.Any()) |
|
{ |
|
_logger.LogWarning( |
|
"Playout {Playout} schedule {Schedule} has no items", |
|
playout.Channel.Name, |
|
playout.ProgramSchedule.Name); |
|
|
|
return None; |
|
} |
|
|
|
Option<CollectionKey> maybeEmptyCollection = await CheckForEmptyCollections(collectionMediaItems); |
|
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) |
|
{ |
|
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); |
|
|
|
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); |
|
} |
|
|
|
// remove any items outside the desired range |
|
playout.Items.RemoveAll(old => old.FinishOffset < trimBefore || old.StartOffset > trimAfter); |
|
|
|
return playout; |
|
} |
|
|
|
private async Task<Playout> BuildPlayoutItems( |
|
Playout playout, |
|
DateTimeOffset playoutStart, |
|
DateTimeOffset playoutFinish, |
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems, |
|
bool saveAnchorDate) |
|
{ |
|
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList(); |
|
CollectionEnumeratorState scheduleItemsEnumeratorState = |
|
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState |
|
{ Seed = Random.Next(), Index = 0 }; |
|
IScheduleItemsEnumerator scheduleItemsEnumerator = playout.ProgramSchedule.ShuffleScheduleItems |
|
? new ShuffledScheduleItemsEnumerator(playout.ProgramSchedule.Items, scheduleItemsEnumeratorState) |
|
: new OrderedScheduleItemsEnumerator(playout.ProgramSchedule.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, collectionKey, mediaItems, playbackOrder); |
|
collectionEnumerators.Add(collectionKey, enumerator); |
|
} |
|
|
|
// find start anchor |
|
PlayoutAnchor startAnchor = FindStartAnchor(playout, playoutStart, scheduleItemsEnumerator); |
|
|
|
// start at the previously-decided time |
|
DateTimeOffset currentTime = startAnchor.NextStartOffset.ToLocalTime(); |
|
// _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(collectionMediaItems, _logger); |
|
var schedulerDuration = new PlayoutModeSchedulerDuration(_logger); |
|
var schedulerFlood = new PlayoutModeSchedulerFlood(_logger); |
|
|
|
// loop until we're done filling the desired amount of time |
|
while (playoutBuilderState.CurrentTime < playoutFinish) |
|
{ |
|
// get the schedule item out of the sorted list |
|
ProgramScheduleItem scheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Current; |
|
|
|
ProgramScheduleItem nextScheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Peek(1); |
|
|
|
Tuple<PlayoutBuilderState, List<PlayoutItem>> result = scheduleItem switch |
|
{ |
|
ProgramScheduleItemMultiple multiple => schedulerMultiple.Schedule( |
|
playoutBuilderState, |
|
collectionEnumerators, |
|
multiple, |
|
nextScheduleItem, |
|
playoutFinish), |
|
ProgramScheduleItemDuration duration => schedulerDuration.Schedule( |
|
playoutBuilderState, |
|
collectionEnumerators, |
|
duration, |
|
nextScheduleItem, |
|
playoutFinish), |
|
ProgramScheduleItemFlood flood => schedulerFlood.Schedule( |
|
playoutBuilderState, |
|
collectionEnumerators, |
|
flood, |
|
nextScheduleItem, |
|
playoutFinish), |
|
ProgramScheduleItemOne one => schedulerOne.Schedule( |
|
playoutBuilderState, |
|
collectionEnumerators, |
|
one, |
|
nextScheduleItem, |
|
playoutFinish), |
|
_ => throw new ArgumentOutOfRangeException(nameof(scheduleItem)) |
|
}; |
|
|
|
(PlayoutBuilderState nextState, List<PlayoutItem> playoutItems) = result; |
|
|
|
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.Any()) |
|
{ |
|
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); |
|
|
|
return playout; |
|
} |
|
|
|
private async Task<Map<CollectionKey, List<MediaItem>>> GetCollectionMediaItems(Playout playout) |
|
{ |
|
var collectionKeys = playout.ProgramSchedule.Items |
|
.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) |
|
{ |
|
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 (item.State == MediaItemState.FileNotFound) |
|
// { |
|
// _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.Any()).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.AnchorDate is null); |
|
|
|
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary( |
|
mcs => mcs.Key, |
|
mcs => mcs.Head()); |
|
|
|
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match( |
|
existing => |
|
{ |
|
existing.EnumeratorState = maybeEnumeratorState[collectionKey]; |
|
return existing; |
|
}, |
|
() => new PlayoutProgramScheduleAnchor |
|
{ |
|
Playout = playout, |
|
PlayoutId = playout.Id, |
|
ProgramSchedule = playout.ProgramSchedule, |
|
ProgramScheduleId = playout.ProgramScheduleId, |
|
CollectionType = collectionKey.CollectionType, |
|
CollectionId = collectionKey.CollectionId, |
|
MultiCollectionId = collectionKey.MultiCollectionId, |
|
SmartCollectionId = collectionKey.SmartCollectionId, |
|
MediaItemId = collectionKey.MediaItemId, |
|
EnumeratorState = maybeEnumeratorState[collectionKey] |
|
}); |
|
|
|
if (saveAnchorDate) |
|
{ |
|
scheduleAnchor.AnchorDate = playout.Anchor?.NextStart; |
|
} |
|
|
|
result.Add(scheduleAnchor); |
|
} |
|
|
|
foreach (PlayoutProgramScheduleAnchor continueAnchor in playout.ProgramScheduleAnchors.Where( |
|
a => a.AnchorDate is not null)) |
|
{ |
|
result.Add(continueAnchor); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator( |
|
Playout playout, |
|
CollectionKey collectionKey, |
|
List<MediaItem> mediaItems, |
|
PlaybackOrder playbackOrder) |
|
{ |
|
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors.FirstOrDefault( |
|
a => a.ProgramScheduleId == playout.ProgramScheduleId |
|
&& a.CollectionType == collectionKey.CollectionType |
|
&& a.CollectionId == collectionKey.CollectionId |
|
&& a.MultiCollectionId == collectionKey.MultiCollectionId |
|
&& a.SmartCollectionId == collectionKey.SmartCollectionId |
|
&& a.MediaItemId == collectionKey.MediaItemId); |
|
|
|
CollectionEnumeratorState state = maybeAnchor.Match( |
|
anchor => anchor.EnumeratorState ?? |
|
(anchor.EnumeratorState = new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }), |
|
() => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }); |
|
|
|
if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0)) |
|
{ |
|
Option<Collection> collectionWithItems = |
|
await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked( |
|
collectionKey.CollectionId ?? 0); |
|
|
|
if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection && |
|
collectionWithItems.IsSome) |
|
{ |
|
return new CustomOrderCollectionEnumerator( |
|
collectionWithItems.ValueUnsafe(), |
|
mediaItems, |
|
state); |
|
} |
|
} |
|
|
|
switch (playbackOrder) |
|
{ |
|
case PlaybackOrder.Chronological: |
|
return new ChronologicalMediaCollectionEnumerator(mediaItems, state); |
|
case PlaybackOrder.Random: |
|
return new RandomizedMediaCollectionEnumerator(mediaItems, state); |
|
case PlaybackOrder.Shuffle: |
|
return new ShuffledMediaCollectionEnumerator( |
|
await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey), |
|
state); |
|
case PlaybackOrder.ShuffleInOrder: |
|
return new ShuffleInOrderCollectionEnumerator( |
|
await GetCollectionItemsForShuffleInOrder(collectionKey), |
|
state); |
|
default: |
|
// TODO: handle this error case differently? |
|
return new RandomizedMediaCollectionEnumerator(mediaItems, state); |
|
} |
|
} |
|
|
|
private async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle( |
|
Playout playout, |
|
List<MediaItem> mediaItems, |
|
CollectionKey collectionKey) |
|
{ |
|
if (collectionKey.MultiCollectionId != null) |
|
{ |
|
List<CollectionWithItems> collections = await _mediaCollectionRepository |
|
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value); |
|
|
|
return MultiCollectionGrouper.GroupMediaItems(collections); |
|
} |
|
|
|
return playout.ProgramSchedule.KeepMultiPartEpisodesTogether |
|
? MultiPartEpisodeGrouper.GroupMediaItems( |
|
mediaItems, |
|
playout.ProgramSchedule.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; |
|
} |
|
} |