* init * minor naming change * address to comments round 1 * update dependencies * formatting * make sure it rotates * update changelog --------- Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
376 lines
14 KiB
C#
376 lines
14 KiB
C#
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Filler;
|
|
using ErsatzTV.Core.Domain.Scheduling;
|
|
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.BlockScheduling;
|
|
|
|
public class BlockPlayoutBuilder(
|
|
IConfigElementRepository configElementRepository,
|
|
IMediaCollectionRepository mediaCollectionRepository,
|
|
ITelevisionRepository televisionRepository,
|
|
IArtistRepository artistRepository,
|
|
ICollectionEtag collectionEtag,
|
|
ILogger<BlockPlayoutBuilder> logger)
|
|
: IBlockPlayoutBuilder
|
|
{
|
|
private static readonly JsonSerializerSettings JsonSettings = new()
|
|
{
|
|
NullValueHandling = NullValueHandling.Ignore
|
|
};
|
|
|
|
protected virtual ILogger Logger => logger;
|
|
|
|
public virtual async Task<Playout> Build(
|
|
Playout playout,
|
|
PlayoutBuildMode mode,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Logger.LogDebug(
|
|
"Building block playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}",
|
|
playout.Id,
|
|
playout.Channel.Number,
|
|
playout.Channel.Name);
|
|
|
|
List<PlaybackOrder> allowedPlaybackOrders =
|
|
[
|
|
PlaybackOrder.Chronological,
|
|
PlaybackOrder.SeasonEpisode,
|
|
PlaybackOrder.Shuffle,
|
|
PlaybackOrder.Random,
|
|
PlaybackOrder.RandomRotation
|
|
];
|
|
|
|
DateTimeOffset start = DateTimeOffset.Now;
|
|
|
|
int daysToBuild = await GetDaysToBuild();
|
|
|
|
// get blocks to schedule
|
|
List<EffectiveBlock> blocksToSchedule =
|
|
EffectiveBlock.GetEffectiveBlocks(playout.Templates, start, daysToBuild);
|
|
|
|
// get all collection items for the playout
|
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(blocksToSchedule);
|
|
if (collectionMediaItems.Values.All(v => v.Count == 0))
|
|
{
|
|
logger.LogWarning("There are no media items to schedule");
|
|
return playout;
|
|
}
|
|
|
|
Map<CollectionKey, string> collectionEtags = GetCollectionEtags(collectionMediaItems);
|
|
|
|
Dictionary<PlayoutItem, BlockKey> itemBlockKeys =
|
|
BlockPlayoutChangeDetection.GetPlayoutItemToBlockKeyMap(playout);
|
|
|
|
// remove items without a block key (shouldn't happen often, just upgrades)
|
|
playout.Items.RemoveAll(i => !itemBlockKeys.ContainsKey(i));
|
|
|
|
// remove old items
|
|
// importantly, this should not remove their history
|
|
playout.Items.RemoveAll(i => i.FinishOffset < start);
|
|
|
|
(List<EffectiveBlock> updatedEffectiveBlocks, List<PlayoutItem> playoutItemsToRemove) =
|
|
BlockPlayoutChangeDetection.FindUpdatedItems(
|
|
playout.Items,
|
|
itemBlockKeys,
|
|
blocksToSchedule,
|
|
collectionEtags);
|
|
|
|
foreach (PlayoutItem playoutItem in playoutItemsToRemove)
|
|
{
|
|
BlockPlayoutChangeDetection.RemoveItemAndHistory(playout, playoutItem);
|
|
}
|
|
|
|
DateTimeOffset currentTime = start;
|
|
if (updatedEffectiveBlocks.Count > 0)
|
|
{
|
|
currentTime = updatedEffectiveBlocks.Min(eb => eb.Start);
|
|
}
|
|
|
|
foreach (EffectiveBlock effectiveBlock in updatedEffectiveBlocks)
|
|
{
|
|
if (currentTime < effectiveBlock.Start)
|
|
{
|
|
currentTime = effectiveBlock.Start;
|
|
|
|
Logger.LogDebug(
|
|
"Will schedule block {Block} at {Start}",
|
|
effectiveBlock.Block.Name,
|
|
effectiveBlock.Start);
|
|
}
|
|
else
|
|
{
|
|
Logger.LogDebug(
|
|
"Will schedule block {Block} with start {Start} at {ActualStart}",
|
|
effectiveBlock.Block.Name,
|
|
effectiveBlock.Start,
|
|
currentTime);
|
|
}
|
|
|
|
DateTimeOffset blockFinish = effectiveBlock.Start.AddMinutes(effectiveBlock.Block.Minutes);
|
|
|
|
foreach (BlockItem blockItem in effectiveBlock.Block.Items.OrderBy(i => i.Index))
|
|
{
|
|
// TODO: support other playback orders
|
|
if (!allowedPlaybackOrders.Contains(blockItem.PlaybackOrder))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (currentTime >= blockFinish)
|
|
{
|
|
Logger.LogDebug(
|
|
"Current time {Time} for block {Block} is beyond block finish {Finish}; will stop with this block's items",
|
|
currentTime,
|
|
effectiveBlock.Block.Name,
|
|
blockFinish);
|
|
|
|
break;
|
|
}
|
|
|
|
// check for playout history for this collection
|
|
string historyKey = HistoryDetails.KeyForBlockItem(blockItem);
|
|
//logger.LogDebug("History key for block item {Item} is {Key}", blockItem.Id, historyKey);
|
|
|
|
IMediaCollectionEnumerator enumerator = GetEnumerator(
|
|
playout,
|
|
blockItem,
|
|
currentTime,
|
|
historyKey,
|
|
collectionMediaItems);
|
|
|
|
var pastTime = false;
|
|
|
|
foreach (MediaItem mediaItem in enumerator.Current)
|
|
{
|
|
Logger.LogDebug(
|
|
"current item: {Id} / {Title}",
|
|
mediaItem.Id,
|
|
mediaItem is Episode e ? GetTitle(e) : string.Empty);
|
|
|
|
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
|
|
|
|
var collectionKey = CollectionKey.ForBlockItem(blockItem);
|
|
|
|
// create a playout item
|
|
var playoutItem = new PlayoutItem
|
|
{
|
|
MediaItemId = mediaItem.Id,
|
|
Start = currentTime.UtcDateTime,
|
|
Finish = currentTime.UtcDateTime + itemDuration,
|
|
InPoint = TimeSpan.Zero,
|
|
OutPoint = itemDuration,
|
|
FillerKind = blockItem.IncludeInProgramGuide ? FillerKind.None : FillerKind.GuideMode,
|
|
//CustomTitle = scheduleItem.CustomTitle,
|
|
//WatermarkId = scheduleItem.WatermarkId,
|
|
//PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
|
|
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
|
|
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
|
|
//SubtitleMode = scheduleItem.SubtitleMode
|
|
GuideGroup = effectiveBlock.TemplateItemId,
|
|
GuideStart = effectiveBlock.Start.UtcDateTime,
|
|
GuideFinish = blockFinish.UtcDateTime,
|
|
BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
|
|
CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
|
|
CollectionEtag = collectionEtags[collectionKey]
|
|
};
|
|
|
|
if (effectiveBlock.Block.StopScheduling is BlockStopScheduling.BeforeDurationEnd
|
|
&& playoutItem.FinishOffset > blockFinish)
|
|
{
|
|
Logger.LogDebug(
|
|
"Current time {Time} for block {Block} would go beyond block finish {Finish}; will not schedule more items",
|
|
currentTime,
|
|
effectiveBlock.Block.Name,
|
|
blockFinish);
|
|
|
|
pastTime = true;
|
|
break;
|
|
}
|
|
|
|
playout.Items.Add(playoutItem);
|
|
|
|
// create a playout history record
|
|
var nextHistory = new PlayoutHistory
|
|
{
|
|
PlayoutId = playout.Id,
|
|
BlockId = blockItem.BlockId,
|
|
PlaybackOrder = blockItem.PlaybackOrder,
|
|
Index = enumerator.State.Index,
|
|
When = currentTime.UtcDateTime,
|
|
Key = historyKey,
|
|
Details = HistoryDetails.ForMediaItem(mediaItem)
|
|
};
|
|
|
|
//logger.LogDebug("Adding history item: {When}: {History}", nextHistory.When, nextHistory.Details);
|
|
playout.PlayoutHistory.Add(nextHistory);
|
|
|
|
currentTime += itemDuration;
|
|
enumerator.MoveNext();
|
|
}
|
|
|
|
if (pastTime)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
CleanUpHistory(playout, start);
|
|
|
|
return playout;
|
|
}
|
|
|
|
protected virtual async Task<int> GetDaysToBuild() =>
|
|
await configElementRepository
|
|
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
|
|
.IfNoneAsync(2);
|
|
|
|
protected virtual IMediaCollectionEnumerator GetEnumerator(
|
|
Playout playout,
|
|
BlockItem blockItem,
|
|
DateTimeOffset currentTime,
|
|
string historyKey,
|
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems)
|
|
{
|
|
var collectionKey = CollectionKey.ForBlockItem(blockItem);
|
|
List<MediaItem> collectionItems = collectionMediaItems[collectionKey];
|
|
|
|
// get enumerator
|
|
IMediaCollectionEnumerator enumerator = blockItem.PlaybackOrder switch
|
|
{
|
|
PlaybackOrder.Chronological => BlockPlayoutEnumerator.Chronological(
|
|
collectionItems,
|
|
currentTime,
|
|
playout,
|
|
blockItem,
|
|
historyKey,
|
|
Logger),
|
|
PlaybackOrder.SeasonEpisode => BlockPlayoutEnumerator.SeasonEpisode(
|
|
collectionItems,
|
|
currentTime,
|
|
playout,
|
|
blockItem,
|
|
historyKey,
|
|
Logger),
|
|
PlaybackOrder.Shuffle => BlockPlayoutEnumerator.Shuffle(
|
|
collectionItems,
|
|
currentTime,
|
|
playout,
|
|
blockItem,
|
|
historyKey),
|
|
PlaybackOrder.RandomRotation => BlockPlayoutEnumerator.RandomRotation(
|
|
collectionItems,
|
|
currentTime,
|
|
playout,
|
|
blockItem,
|
|
historyKey),
|
|
_ => new RandomizedMediaCollectionEnumerator(
|
|
collectionItems,
|
|
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 })
|
|
};
|
|
|
|
return enumerator;
|
|
}
|
|
|
|
private static string GetTitle(Episode e)
|
|
{
|
|
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
|
|
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
|
|
var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList();
|
|
var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList();
|
|
if (episodeNumbers.Count == 0 || episodeTitles.Count == 0)
|
|
{
|
|
return "[unknown episode]";
|
|
}
|
|
|
|
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
|
|
var titlesString = $"{string.Join('/', episodeTitles)}";
|
|
|
|
return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}";
|
|
}
|
|
|
|
private static void CleanUpHistory(Playout playout, DateTimeOffset start)
|
|
{
|
|
var groups = new Dictionary<string, List<PlayoutHistory>>();
|
|
foreach (PlayoutHistory history in playout.PlayoutHistory)
|
|
{
|
|
var key = $"{history.BlockId}-{history.Key}";
|
|
if (!groups.TryGetValue(key, out List<PlayoutHistory> group))
|
|
{
|
|
group = [];
|
|
groups[key] = group;
|
|
}
|
|
|
|
group.Add(history);
|
|
}
|
|
|
|
foreach ((string _, List<PlayoutHistory> group) in groups)
|
|
{
|
|
//logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count);
|
|
|
|
IEnumerable<PlayoutHistory> toDelete = group
|
|
.Filter(h => h.When < start.UtcDateTime)
|
|
.OrderByDescending(h => h.When)
|
|
.Tail();
|
|
|
|
foreach (PlayoutHistory delete in toDelete)
|
|
{
|
|
playout.PlayoutHistory.Remove(delete);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<Map<CollectionKey, List<MediaItem>>> GetCollectionMediaItems(
|
|
List<EffectiveBlock> effectiveBlocks)
|
|
{
|
|
var collectionKeys = effectiveBlocks.Map(b => b.Block.Items)
|
|
.Flatten()
|
|
.DistinctBy(i => i.Id)
|
|
.Map(CollectionKey.ForBlockItem)
|
|
.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 Map<CollectionKey, string> GetCollectionEtags(
|
|
Map<CollectionKey, List<MediaItem>> collectionMediaItems)
|
|
{
|
|
var result = new Map<CollectionKey, string>();
|
|
|
|
foreach ((CollectionKey key, List<MediaItem> items) in collectionMediaItems)
|
|
{
|
|
result = result.Add(key, collectionEtag.ForCollectionItems(items));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static TimeSpan DurationForMediaItem(MediaItem mediaItem)
|
|
{
|
|
if (mediaItem is Image image)
|
|
{
|
|
return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds);
|
|
}
|
|
|
|
MediaVersion version = mediaItem.GetHeadVersion();
|
|
return version.Duration;
|
|
}
|
|
}
|