Stream custom live channels using your own media
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.
 
 
 

336 lines
15 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 Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Map = LanguageExt.Map;
namespace ErsatzTV.Core.Scheduling
{
public class PlayoutBuilder : IPlayoutBuilder
{
private static readonly Random Random = new();
private readonly ILogger<PlayoutBuilder> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public PlayoutBuilder(
IMediaCollectionRepository mediaCollectionRepository,
ILogger<PlayoutBuilder> logger)
{
_mediaCollectionRepository = mediaCollectionRepository;
_logger = logger;
}
public Task<Playout> BuildPlayoutItems(Playout playout, bool rebuild = false)
{
DateTimeOffset now = DateTimeOffset.Now;
return BuildPlayoutItems(playout, now, now.AddDays(2), rebuild);
}
public async Task<Playout> BuildPlayoutItems(
Playout playout,
DateTimeOffset start,
DateTimeOffset finish,
bool rebuild = false)
{
var collections = playout.ProgramSchedule.Items.Map(i => i.MediaCollection).Distinct().ToList();
IEnumerable<Tuple<MediaCollection, List<MediaItem>>> tuples = await collections.Map(
async collection =>
{
Option<List<MediaItem>> maybeItems = await _mediaCollectionRepository.GetItems(collection.Id);
return Tuple(collection, maybeItems.IfNone(new List<MediaItem>()));
}).Sequence();
var collectionMediaItems = Map.createRange(tuples);
// using IDisposable scope = _logger.BeginScope(new { PlayoutId = playout.Id });
_logger.LogDebug(
$"{(rebuild ? "Rebuilding" : "Building")} playout {{PlayoutId}} for channel {{ChannelNumber}} - {{ChannelName}}",
playout.Id,
playout.Channel.Number,
playout.Channel.Name);
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();
Map<MediaCollection, IMediaCollectionEnumerator> collectionEnumerators =
MapExtensions.Map(collectionMediaItems, (c, i) => GetMediaCollectionEnumerator(playout, c, i));
// find start anchor
PlayoutAnchor startAnchor = FindStartAnchor(playout, start, sortedScheduleItems);
// start at the previously-decided time
DateTimeOffset currentTime = startAnchor.NextStart;
_logger.LogDebug(
"Starting playout {PlayoutId} for channel {ChannelNumber} - {ChannelName} at {StartTime}",
playout.Id,
playout.Channel.Number,
playout.Channel.Name,
currentTime);
// start with the previously-decided schedule item
int index = sortedScheduleItems.IndexOf(startAnchor.NextScheduleItem);
Option<int> multipleRemaining = None;
Option<DateTimeOffset> durationFinish = None;
// loop until we're done filling the desired amount of time
while (currentTime < finish)
{
// 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 startTime = GetStartTimeAfter(
scheduleItem,
currentTime,
multipleRemaining.IsSome,
durationFinish.IsSome);
_logger.LogDebug(
"Schedule item: {ScheduleItemNumber} / {MediaCollectionName} / {StartTime}",
scheduleItem.Index,
scheduleItem.MediaCollection.Name,
startTime);
IMediaCollectionEnumerator enumerator = collectionEnumerators[scheduleItem.MediaCollection];
enumerator.Current.IfSome(
mediaItem =>
{
var playoutItem = new PlayoutItem
{
MediaItemId = mediaItem.Id,
Start = startTime,
Finish = startTime + mediaItem.Metadata.Duration
};
currentTime = startTime + mediaItem.Metadata.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 playout item after playout mode {PlayoutMode}",
"One");
index++;
break;
case ProgramScheduleItemMultiple multiple:
if (multipleRemaining.IsNone)
{
multipleRemaining = multiple.Count;
}
multipleRemaining = multipleRemaining.Map(i => i - 1);
if (multipleRemaining.IfNone(-1) == 0)
{
index++;
multipleRemaining = None;
}
break;
case ProgramScheduleItemFlood:
enumerator.Peek.Do(
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 + peekMediaItem.Metadata.Duration >
peekScheduleItemStart;
if (willNotFinishInTime)
{
index++;
}
});
break;
case ProgramScheduleItemDuration duration:
enumerator.Peek.Do(
peekMediaItem =>
{
if (durationFinish.IsNone)
{
durationFinish = startTime + duration.PlayoutDuration;
}
DateTimeOffset finish = durationFinish.IfNone(DateTime.MinValue);
bool willNotFinishInTime = currentTime <= finish &&
currentTime + peekMediaItem.Metadata.Duration >
finish;
if (willNotFinishInTime)
{
index++;
if (duration.OfflineTail)
{
durationFinish.Do(f => currentTime = f);
}
durationFinish = None;
}
}
);
break;
}
});
}
// once more to get playout anchor
ProgramScheduleItem nextScheduleItem = sortedScheduleItems[index % sortedScheduleItems.Count];
playout.Anchor = new PlayoutAnchor
{
NextScheduleItem = nextScheduleItem,
NextScheduleItemId = nextScheduleItem.Id,
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime)
};
// build program schedule anchors
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators);
// remove any items outside the desired range
playout.Items.RemoveAll(old => old.Finish < start || old.Start > finish);
return playout;
}
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.Date + schedule.StartTime.GetValueOrDefault()
};
case StartType.Dynamic:
default:
return new PlayoutAnchor
{
NextScheduleItem = schedule,
NextScheduleItemId = schedule.Id,
NextStart = start.Date
};
}
});
private static DateTimeOffset GetStartTimeAfter(
ProgramScheduleItem item,
DateTimeOffset start,
bool inMultiple = false,
bool inDuration = false)
{
switch (item.StartType)
{
case StartType.Fixed:
if (item is ProgramScheduleItemMultiple && inMultiple ||
item is ProgramScheduleItemDuration && inDuration)
{
return start;
}
TimeSpan startTime = item.StartTime.GetValueOrDefault();
DateTime 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,
Map<MediaCollection, IMediaCollectionEnumerator> collectionEnumerators)
{
var result = new List<PlayoutProgramScheduleAnchor>();
foreach (MediaCollection collection in collectionEnumerators.Keys)
{
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors
.FirstOrDefault(a => a.MediaCollection == collection);
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[collection];
return existing;
},
() => new PlayoutProgramScheduleAnchor
{
Playout = playout,
PlayoutId = playout.Id,
ProgramSchedule = playout.ProgramSchedule,
ProgramScheduleId = playout.ProgramScheduleId,
MediaCollection = collection,
MediaCollectionId = collection.Id,
EnumeratorState = maybeEnumeratorState[collection]
});
result.Add(scheduleAnchor);
}
return result;
}
private static IMediaCollectionEnumerator GetMediaCollectionEnumerator(
Playout playout,
MediaCollection mediaCollection,
List<MediaItem> mediaItems)
{
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
.FirstOrDefault(
a => a.ProgramScheduleId == playout.ProgramScheduleId && a.MediaCollectionId == mediaCollection.Id);
MediaCollectionEnumeratorState state = maybeAnchor.Match(
anchor => anchor.EnumeratorState,
() => new MediaCollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
switch (playout.ProgramSchedule.MediaCollectionPlaybackOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(mediaItems, state);
case PlaybackOrder.Random:
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
case PlaybackOrder.Shuffle:
return new ShuffledMediaCollectionEnumerator(mediaItems, state);
default:
// TODO: handle this error case differently?
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
}
}
}
}