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.
856 lines
41 KiB
856 lines
41 KiB
using System; |
|
using System.Collections.Generic; |
|
using System.Linq; |
|
using System.Threading.Tasks; |
|
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.Interfaces.Repositories; |
|
using ErsatzTV.Core.Interfaces.Scheduling; |
|
using LanguageExt; |
|
using LanguageExt.UnsafeValueAccess; |
|
using Microsoft.Extensions.Logging; |
|
using static LanguageExt.Prelude; |
|
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> BuildPlayoutItems(Playout playout, bool rebuild = false) |
|
{ |
|
DateTimeOffset now = DateTimeOffset.Now; |
|
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild); |
|
return await BuildPlayoutItems(playout, now, now.AddDays(await daysToBuild.IfNoneAsync(2)), rebuild); |
|
} |
|
|
|
public async Task<Playout> BuildPlayoutItems( |
|
Playout playout, |
|
DateTimeOffset playoutStart, |
|
DateTimeOffset playoutFinish, |
|
bool rebuild = false) |
|
{ |
|
var collectionKeys = playout.ProgramSchedule.Items |
|
.SelectMany(CollectionKeysForItem) |
|
.Distinct() |
|
.ToList(); |
|
|
|
if (!collectionKeys.Any()) |
|
{ |
|
_logger.LogWarning( |
|
"Playout {Playout} schedule {Schedule} has no items", |
|
playout.Channel.Name, |
|
playout.ProgramSchedule.Name); |
|
return playout; |
|
} |
|
|
|
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> tuples = await collectionKeys.Map( |
|
async collectionKey => |
|
{ |
|
switch (collectionKey.CollectionType) |
|
{ |
|
case ProgramScheduleItemCollectionType.Collection: |
|
List<MediaItem> collectionItems = |
|
await _mediaCollectionRepository.GetItems(collectionKey.CollectionId ?? 0); |
|
return Tuple(collectionKey, collectionItems); |
|
case ProgramScheduleItemCollectionType.TelevisionShow: |
|
List<Episode> showItems = |
|
await _televisionRepository.GetShowItems(collectionKey.MediaItemId ?? 0); |
|
return Tuple(collectionKey, showItems.Cast<MediaItem>().ToList()); |
|
case ProgramScheduleItemCollectionType.TelevisionSeason: |
|
List<Episode> seasonItems = |
|
await _televisionRepository.GetSeasonItems(collectionKey.MediaItemId ?? 0); |
|
return Tuple(collectionKey, seasonItems.Cast<MediaItem>().ToList()); |
|
case ProgramScheduleItemCollectionType.Artist: |
|
List<MusicVideo> artistItems = |
|
await _artistRepository.GetArtistItems(collectionKey.MediaItemId ?? 0); |
|
return Tuple(collectionKey, artistItems.Cast<MediaItem>().ToList()); |
|
case ProgramScheduleItemCollectionType.MultiCollection: |
|
List<MediaItem> multiCollectionItems = |
|
await _mediaCollectionRepository.GetMultiCollectionItems( |
|
collectionKey.MultiCollectionId ?? 0); |
|
return Tuple(collectionKey, multiCollectionItems); |
|
case ProgramScheduleItemCollectionType.SmartCollection: |
|
List<MediaItem> smartCollectionItems = |
|
await _mediaCollectionRepository.GetSmartCollectionItems( |
|
collectionKey.SmartCollectionId ?? 0); |
|
return Tuple(collectionKey, smartCollectionItems); |
|
default: |
|
return Tuple(collectionKey, new List<MediaItem>()); |
|
} |
|
}).Sequence(); |
|
|
|
var collectionMediaItems = Map.createRange(tuples); |
|
|
|
_logger.LogDebug( |
|
"{Action} playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", |
|
rebuild ? "Rebuilding" : "Building", |
|
playout.Id, |
|
playout.Channel.Number, |
|
playout.Channel.Name); |
|
|
|
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems) |
|
{ |
|
var zeroItems = 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, |
|
_ => true |
|
}; |
|
|
|
if (isZero) |
|
{ |
|
_logger.LogWarning( |
|
"Skipping media item with zero duration {MediaItem} - {MediaItemTitle}", |
|
item.Id, |
|
DisplayTitle(item)); |
|
|
|
zeroItems.Add(item); |
|
} |
|
} |
|
|
|
items.RemoveAll(i => zeroItems.Contains(i)); |
|
} |
|
|
|
// this guard needs to be below the place where we modify the collections (by removing zero-duration items) |
|
Option<CollectionKey> emptyCollection = |
|
collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key); |
|
if (emptyCollection.IsSome) |
|
{ |
|
_logger.LogError( |
|
"Unable to rebuild playout; collection {@CollectionKey} has no valid items!", |
|
emptyCollection.ValueUnsafe()); |
|
|
|
return playout; |
|
} |
|
|
|
// leaving this guard in for a while to ensure the zero item removal is working properly |
|
Option<CollectionKey> zeroDurationCollection = collectionMediaItems.Find( |
|
c => c.Value.Any( |
|
mi => mi switch |
|
{ |
|
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration) |
|
.IfNone(TimeSpan.Zero) == TimeSpan.Zero, |
|
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration) |
|
.IfNone(TimeSpan.Zero) == TimeSpan.Zero, |
|
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration) |
|
.IfNone(TimeSpan.Zero) == TimeSpan.Zero, |
|
OtherVideo ov => ov.MediaVersions.HeadOrNone().Map(v => v.Duration) |
|
.IfNone(TimeSpan.Zero) == TimeSpan.Zero, |
|
_ => true |
|
})).Map(c => c.Key); |
|
if (zeroDurationCollection.IsSome) |
|
{ |
|
_logger.LogError( |
|
"BUG: Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!", |
|
zeroDurationCollection.ValueUnsafe()); |
|
|
|
return playout; |
|
} |
|
|
|
playout.Items ??= new List<PlayoutItem>(); |
|
playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>(); |
|
|
|
if (rebuild) |
|
{ |
|
playout.Items.Clear(); |
|
playout.Anchor = null; |
|
playout.ProgramScheduleAnchors.Clear(); |
|
} |
|
|
|
var sortedScheduleItems = |
|
playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList(); |
|
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 => CollectionKeyForItem(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, sortedScheduleItems); |
|
|
|
// 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 |
|
int index = sortedScheduleItems.IndexOf(startAnchor.NextScheduleItem); |
|
|
|
// start with the previous multiple/duration states |
|
Option<int> multipleRemaining = Optional(startAnchor.MultipleRemaining); |
|
Option<DateTimeOffset> durationFinish = startAnchor.DurationFinishOffset; |
|
bool inFlood = startAnchor.InFlood; |
|
bool inDurationFiller = startAnchor.InDurationFiller; |
|
|
|
bool customGroup = multipleRemaining.IsSome || durationFinish.IsSome; |
|
|
|
// loop until we're done filling the desired amount of time |
|
while (currentTime < playoutFinish) |
|
{ |
|
// get the schedule item out of the sorted list |
|
ProgramScheduleItem scheduleItem = sortedScheduleItems[index % sortedScheduleItems.Count]; |
|
|
|
// find when we should start this item, based on the current time |
|
DateTimeOffset itemStartTime = GetStartTimeAfter( |
|
scheduleItem, |
|
currentTime, |
|
multipleRemaining.IsSome, |
|
durationFinish.IsSome, |
|
inFlood, |
|
inDurationFiller); |
|
|
|
Option<CollectionKey> maybeTailCollectionKey = Option<CollectionKey>.None; |
|
if (inDurationFiller && scheduleItem is ProgramScheduleItemDuration |
|
{ |
|
TailMode: TailMode.Filler |
|
}) |
|
{ |
|
maybeTailCollectionKey = TailCollectionKeyForItem(scheduleItem); |
|
} |
|
|
|
IMediaCollectionEnumerator enumerator = collectionEnumerators[CollectionKeyForItem(scheduleItem)]; |
|
foreach (CollectionKey tailCollectionKey in maybeTailCollectionKey) |
|
{ |
|
enumerator = collectionEnumerators[tailCollectionKey]; |
|
} |
|
|
|
await enumerator.Current.IfSomeAsync( |
|
mediaItem => |
|
{ |
|
_logger.LogDebug( |
|
"Scheduling media item: {ScheduleItemNumber} / {CollectionType} / {MediaItemId} - {MediaItemTitle} / {StartTime}", |
|
scheduleItem.Index, |
|
inDurationFiller |
|
? (scheduleItem as ProgramScheduleItemDuration)?.TailCollectionType |
|
: scheduleItem.CollectionType, |
|
mediaItem.Id, |
|
DisplayTitle(mediaItem), |
|
itemStartTime); |
|
|
|
MediaVersion version = mediaItem switch |
|
{ |
|
Movie m => m.MediaVersions.Head(), |
|
Episode e => e.MediaVersions.Head(), |
|
MusicVideo mv => mv.MediaVersions.Head(), |
|
OtherVideo mv => mv.MediaVersions.Head(), |
|
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) |
|
}; |
|
|
|
var playoutItem = new PlayoutItem |
|
{ |
|
MediaItemId = mediaItem.Id, |
|
Start = itemStartTime.UtcDateTime, |
|
Finish = itemStartTime.UtcDateTime + version.Duration, |
|
CustomGroup = customGroup, |
|
IsFiller = inDurationFiller || scheduleItem.GuideMode == GuideMode.Filler |
|
}; |
|
|
|
if (!string.IsNullOrWhiteSpace(scheduleItem.CustomTitle)) |
|
{ |
|
playoutItem.CustomTitle = scheduleItem.CustomTitle; |
|
} |
|
|
|
currentTime = itemStartTime + version.Duration; |
|
enumerator.MoveNext(); |
|
|
|
playout.Items.Add(playoutItem); |
|
|
|
switch (scheduleItem) |
|
{ |
|
case ProgramScheduleItemOne: |
|
// only play one item from collection, so always advance to the next item |
|
_logger.LogDebug( |
|
"Advancing to next schedule item after playout mode {PlayoutMode}", |
|
"One"); |
|
index++; |
|
customGroup = false; |
|
break; |
|
case ProgramScheduleItemMultiple multiple: |
|
if (multipleRemaining.IsNone) |
|
{ |
|
if (multiple.Count == 0) |
|
{ |
|
multipleRemaining = collectionMediaItems[CollectionKeyForItem(scheduleItem)] |
|
.Count; |
|
} |
|
else |
|
{ |
|
multipleRemaining = multiple.Count; |
|
} |
|
|
|
customGroup = true; |
|
} |
|
|
|
multipleRemaining = multipleRemaining.Map(i => i - 1); |
|
if (multipleRemaining.IfNone(-1) == 0) |
|
{ |
|
_logger.LogDebug( |
|
"Advancing to next schedule item after playout mode {PlayoutMode}", |
|
"Multiple"); |
|
index++; |
|
multipleRemaining = None; |
|
customGroup = false; |
|
} |
|
|
|
break; |
|
case ProgramScheduleItemFlood: |
|
enumerator.Current.Do( |
|
peekMediaItem => |
|
{ |
|
customGroup = true; |
|
|
|
MediaVersion peekVersion = peekMediaItem switch |
|
{ |
|
Movie m => m.MediaVersions.Head(), |
|
Episode e => e.MediaVersions.Head(), |
|
MusicVideo mv => mv.MediaVersions.Head(), |
|
OtherVideo ov => ov.MediaVersions.Head(), |
|
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem)) |
|
}; |
|
|
|
ProgramScheduleItem peekScheduleItem = |
|
sortedScheduleItems[(index + 1) % sortedScheduleItems.Count]; |
|
DateTimeOffset peekScheduleItemStart = |
|
peekScheduleItem.StartType == StartType.Fixed |
|
? GetStartTimeAfter(peekScheduleItem, currentTime) |
|
: DateTimeOffset.MaxValue; |
|
|
|
// if the current time is before the next schedule item, but the current finish |
|
// is after, we need to move on to the next schedule item |
|
// eventually, spots probably have to fit in this gap |
|
bool willNotFinishInTime = currentTime <= peekScheduleItemStart && |
|
currentTime + peekVersion.Duration > |
|
peekScheduleItemStart; |
|
if (willNotFinishInTime) |
|
{ |
|
_logger.LogDebug( |
|
"Advancing to next schedule item after playout mode {PlayoutMode}", |
|
"Flood"); |
|
index++; |
|
customGroup = false; |
|
inFlood = false; |
|
} |
|
else |
|
{ |
|
inFlood = true; |
|
} |
|
}); |
|
break; |
|
case ProgramScheduleItemDuration duration: |
|
enumerator.Current.Do( |
|
peekMediaItem => |
|
{ |
|
MediaVersion peekVersion = peekMediaItem switch |
|
{ |
|
Movie m => m.MediaVersions.Head(), |
|
Episode e => e.MediaVersions.Head(), |
|
MusicVideo mv => mv.MediaVersions.Head(), |
|
OtherVideo ov => ov.MediaVersions.Head(), |
|
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem)) |
|
}; |
|
|
|
// remember when we need to finish this duration item |
|
if (durationFinish.IsNone) |
|
{ |
|
durationFinish = itemStartTime + duration.PlayoutDuration; |
|
customGroup = true; |
|
} |
|
|
|
bool willNotFinishInTime = |
|
currentTime <= durationFinish.IfNone(SystemTime.MinValueUtc) && |
|
currentTime + peekVersion.Duration > |
|
durationFinish.IfNone(SystemTime.MinValueUtc); |
|
if (willNotFinishInTime) |
|
{ |
|
_logger.LogDebug( |
|
"Advancing to next schedule item after playout mode {PlayoutMode}", |
|
"Duration"); |
|
index++; |
|
|
|
if (duration.TailMode == TailMode.Offline) |
|
{ |
|
durationFinish.Do(f => currentTime = f); |
|
} |
|
|
|
if (duration.TailMode != TailMode.Filler || inDurationFiller) |
|
{ |
|
if (duration.TailMode != TailMode.None) |
|
{ |
|
durationFinish.Do(f => currentTime = f); |
|
} |
|
|
|
durationFinish = None; |
|
inDurationFiller = false; |
|
customGroup = false; |
|
} |
|
else if (duration.TailMode == TailMode.Filler && |
|
WillFinishFillerInTime( |
|
scheduleItem, |
|
currentTime, |
|
durationFinish, |
|
collectionEnumerators)) |
|
{ |
|
inDurationFiller = true; |
|
durationFinish.Do( |
|
f => playoutItem.GuideFinish = f.UtcDateTime); |
|
} |
|
} |
|
} |
|
); |
|
break; |
|
} |
|
}); |
|
} |
|
|
|
// once more to get playout anchor |
|
ProgramScheduleItem nextScheduleItem = sortedScheduleItems[index % sortedScheduleItems.Count]; |
|
|
|
// build program schedule anchors |
|
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators); |
|
|
|
// remove any items outside the desired range |
|
playout.Items.RemoveAll( |
|
old => old.FinishOffset < playoutStart.AddHours(-4) || old.StartOffset > playoutFinish); |
|
|
|
DateTimeOffset minCurrentTime = currentTime; |
|
if (playout.Items.Any()) |
|
{ |
|
DateTimeOffset maxStartTime = playout.Items.Max(i => i.FinishOffset); |
|
if (maxStartTime < currentTime) |
|
{ |
|
minCurrentTime = maxStartTime; |
|
} |
|
} |
|
|
|
playout.Anchor = new PlayoutAnchor |
|
{ |
|
NextScheduleItem = nextScheduleItem, |
|
NextScheduleItemId = nextScheduleItem.Id, |
|
NextStart = GetStartTimeAfter(nextScheduleItem, minCurrentTime).UtcDateTime, |
|
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null, |
|
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null, |
|
InFlood = inFlood, |
|
InDurationFiller = inDurationFiller |
|
}; |
|
|
|
return playout; |
|
} |
|
|
|
private static bool WillFinishFillerInTime( |
|
ProgramScheduleItem scheduleItem, |
|
DateTimeOffset currentTime, |
|
Option<DateTimeOffset> durationFinish, |
|
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators) |
|
{ |
|
Option<CollectionKey> maybeTailCollectionKey = Option<CollectionKey>.None; |
|
if (scheduleItem is ProgramScheduleItemDuration |
|
{ |
|
TailMode: TailMode.Filler |
|
}) |
|
{ |
|
maybeTailCollectionKey = TailCollectionKeyForItem(scheduleItem); |
|
} |
|
|
|
foreach (CollectionKey collectionKey in maybeTailCollectionKey) |
|
{ |
|
IMediaCollectionEnumerator enumerator = collectionEnumerators[collectionKey]; |
|
Option<int> firstId = enumerator.Current.Map(i => i.Id); |
|
while (true) |
|
{ |
|
foreach (MediaItem peekMediaItem in enumerator.Current) |
|
{ |
|
MediaVersion peekVersion = peekMediaItem switch |
|
{ |
|
Movie m => m.MediaVersions.Head(), |
|
Episode e => e.MediaVersions.Head(), |
|
MusicVideo mv => mv.MediaVersions.Head(), |
|
OtherVideo ov => ov.MediaVersions.Head(), |
|
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem)) |
|
}; |
|
|
|
if (currentTime + peekVersion.Duration <= durationFinish.IfNone(SystemTime.MinValueUtc)) |
|
{ |
|
return true; |
|
} |
|
} |
|
|
|
enumerator.MoveNext(); |
|
if (enumerator.Current.Map(i => i.Id) == firstId) |
|
{ |
|
return false; |
|
} |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
private static PlayoutAnchor FindStartAnchor( |
|
Playout playout, |
|
DateTimeOffset start, |
|
IReadOnlyCollection<ProgramScheduleItem> sortedScheduleItems) => |
|
Optional(playout.Anchor).IfNone( |
|
() => |
|
{ |
|
ProgramScheduleItem schedule = sortedScheduleItems.Head(); |
|
switch (schedule.StartType) |
|
{ |
|
case StartType.Fixed: |
|
return new PlayoutAnchor |
|
{ |
|
NextScheduleItem = schedule, |
|
NextScheduleItemId = schedule.Id, |
|
NextStart = (start - start.TimeOfDay).UtcDateTime + |
|
schedule.StartTime.GetValueOrDefault() |
|
}; |
|
case StartType.Dynamic: |
|
default: |
|
return new PlayoutAnchor |
|
{ |
|
NextScheduleItem = schedule, |
|
NextScheduleItemId = schedule.Id, |
|
NextStart = (start - start.TimeOfDay).UtcDateTime |
|
}; |
|
} |
|
}); |
|
|
|
private static DateTimeOffset GetStartTimeAfter( |
|
ProgramScheduleItem item, |
|
DateTimeOffset start, |
|
bool inMultiple = false, |
|
bool inDuration = false, |
|
bool inFlood = false, |
|
bool inDurationFiller = false) |
|
{ |
|
switch (item.StartType) |
|
{ |
|
case StartType.Fixed: |
|
if (item is ProgramScheduleItemMultiple && inMultiple || |
|
item is ProgramScheduleItemDuration && inDuration || |
|
item is ProgramScheduleItemFlood && inFlood || |
|
item is ProgramScheduleItemDuration && inDurationFiller) |
|
{ |
|
return start; |
|
} |
|
|
|
TimeSpan startTime = item.StartTime.GetValueOrDefault(); |
|
DateTimeOffset result = start.Date + startTime; |
|
// need to wrap to the next day if appropriate |
|
return start.TimeOfDay > startTime ? result.AddDays(1) : result; |
|
case StartType.Dynamic: |
|
default: |
|
return start; |
|
} |
|
} |
|
|
|
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors( |
|
Playout playout, |
|
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators) |
|
{ |
|
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); |
|
|
|
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] |
|
}); |
|
|
|
result.Add(scheduleAnchor); |
|
} |
|
|
|
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, |
|
() => 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) |
|
{ |
|
var result = new List<CollectionWithItems>(); |
|
|
|
if (collectionKey.MultiCollectionId != null) |
|
{ |
|
result = await _mediaCollectionRepository.GetMultiCollectionCollections( |
|
collectionKey.MultiCollectionId.Value); |
|
} |
|
else |
|
{ |
|
result = await _mediaCollectionRepository.GetFakeMultiCollectionCollections( |
|
collectionKey.CollectionId, |
|
collectionKey.SmartCollectionId); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
private 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]"); |
|
default: |
|
return string.Empty; |
|
} |
|
} |
|
|
|
private static List<CollectionKey> CollectionKeysForItem(ProgramScheduleItem item) |
|
{ |
|
var result = new List<CollectionKey> { CollectionKeyForItem(item) }; |
|
result.AddRange(TailCollectionKeyForItem(item)); |
|
return result; |
|
} |
|
|
|
private static CollectionKey CollectionKeyForItem(ProgramScheduleItem item) => |
|
item.CollectionType switch |
|
{ |
|
ProgramScheduleItemCollectionType.Collection => new CollectionKey |
|
{ |
|
CollectionType = item.CollectionType, |
|
CollectionId = item.CollectionId |
|
}, |
|
ProgramScheduleItemCollectionType.TelevisionShow => new CollectionKey |
|
{ |
|
CollectionType = item.CollectionType, |
|
MediaItemId = item.MediaItemId |
|
}, |
|
ProgramScheduleItemCollectionType.TelevisionSeason => new CollectionKey |
|
{ |
|
CollectionType = item.CollectionType, |
|
MediaItemId = item.MediaItemId |
|
}, |
|
ProgramScheduleItemCollectionType.Artist => new CollectionKey |
|
{ |
|
CollectionType = item.CollectionType, |
|
MediaItemId = item.MediaItemId |
|
}, |
|
ProgramScheduleItemCollectionType.MultiCollection => new CollectionKey |
|
{ |
|
CollectionType = item.CollectionType, |
|
MultiCollectionId = item.MultiCollectionId |
|
}, |
|
ProgramScheduleItemCollectionType.SmartCollection => new CollectionKey |
|
{ |
|
CollectionType = item.CollectionType, |
|
SmartCollectionId = item.SmartCollectionId |
|
}, |
|
_ => throw new ArgumentOutOfRangeException(nameof(item)) |
|
}; |
|
|
|
private static Option<CollectionKey> TailCollectionKeyForItem(ProgramScheduleItem item) |
|
{ |
|
if (item is ProgramScheduleItemDuration { TailMode: TailMode.Filler } duration) |
|
{ |
|
return duration.TailCollectionType switch |
|
{ |
|
ProgramScheduleItemCollectionType.Collection => new CollectionKey |
|
{ |
|
CollectionType = duration.TailCollectionType, |
|
CollectionId = duration.TailCollectionId |
|
}, |
|
ProgramScheduleItemCollectionType.TelevisionShow => new CollectionKey |
|
{ |
|
CollectionType = duration.TailCollectionType, |
|
MediaItemId = duration.TailMediaItemId |
|
}, |
|
ProgramScheduleItemCollectionType.TelevisionSeason => new CollectionKey |
|
{ |
|
CollectionType = duration.TailCollectionType, |
|
MediaItemId = duration.TailMediaItemId |
|
}, |
|
ProgramScheduleItemCollectionType.Artist => new CollectionKey |
|
{ |
|
CollectionType = duration.TailCollectionType, |
|
MediaItemId = duration.TailMediaItemId |
|
}, |
|
ProgramScheduleItemCollectionType.MultiCollection => new CollectionKey |
|
{ |
|
CollectionType = duration.TailCollectionType, |
|
MultiCollectionId = duration.TailMultiCollectionId |
|
}, |
|
ProgramScheduleItemCollectionType.SmartCollection => new CollectionKey |
|
{ |
|
CollectionType = duration.TailCollectionType, |
|
SmartCollectionId = duration.TailSmartCollectionId |
|
}, |
|
_ => throw new ArgumentOutOfRangeException(nameof(item)) |
|
}; |
|
} |
|
|
|
return None; |
|
} |
|
|
|
private class CollectionKey : Record<CollectionKey> |
|
{ |
|
public ProgramScheduleItemCollectionType CollectionType { get; set; } |
|
public int? CollectionId { get; set; } |
|
public int? MultiCollectionId { get; set; } |
|
public int? SmartCollectionId { get; set; } |
|
public int? MediaItemId { get; set; } |
|
} |
|
} |
|
}
|
|
|