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; namespace ErsatzTV.Core.Scheduling.BlockScheduling; public class BlockPlayoutBuilder( IConfigElementRepository configElementRepository, IMediaCollectionRepository mediaCollectionRepository, ITelevisionRepository televisionRepository, IArtistRepository artistRepository, ICollectionEtag collectionEtag, ILogger logger) : IBlockPlayoutBuilder { private static readonly JsonSerializerSettings JsonSettings = new() { NullValueHandling = NullValueHandling.Ignore }; public virtual async Task 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); var random = new Random(); List allowedPlaybackOrders = [ PlaybackOrder.Chronological, PlaybackOrder.SeasonEpisode, PlaybackOrder.Shuffle, PlaybackOrder.Random ]; DateTimeOffset start = DateTimeOffset.Now; int daysToBuild = await GetDaysToBuild(); // get blocks to schedule List blocksToSchedule = EffectiveBlock.GetEffectiveBlocks(playout.Templates, start, daysToBuild); // get all collection items for the playout Map> collectionMediaItems = await GetCollectionMediaItems(blocksToSchedule); Map collectionEtags = GetCollectionEtags(collectionMediaItems); Dictionary 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 updatedEffectiveBlocks, List playoutItemsToRemove) = BlockPlayoutChangeDetection.FindUpdatedItems( playout.Items, itemBlockKeys, blocksToSchedule, collectionEtags); foreach (PlayoutItem playoutItem in playoutItemsToRemove) { BlockPlayoutChangeDetection.RemoveItemAndHistory(playout, playoutItem); } DateTimeOffset currentTime = 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); 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 = FillerKind.None, //CustomTitle = scheduleItem.CustomTitle, //WatermarkId = scheduleItem.WatermarkId, //PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, //PreferredAudioTitle = scheduleItem.PreferredAudioTitle, //PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, //SubtitleMode = scheduleItem.SubtitleMode GuideGroup = random.Next(), 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); 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(); } } } CleanUpHistory(playout, start); return playout; } protected virtual ILogger Logger => logger; protected virtual async Task GetDaysToBuild() { return await configElementRepository .GetValue(ConfigElementKey.PlayoutDaysToBuild) .IfNoneAsync(2); } protected virtual IMediaCollectionEnumerator GetEnumerator( Playout playout, BlockItem blockItem, DateTimeOffset currentTime, string historyKey, Map> collectionMediaItems) { var collectionKey = CollectionKey.ForBlockItem(blockItem); List 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), _ => 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>(); foreach (PlayoutHistory history in playout.PlayoutHistory) { var key = $"{history.BlockId}-{history.Key}"; if (!groups.TryGetValue(key, out List group)) { group = []; groups[key] = group; } group.Add(history); } foreach ((string _, List group) in groups) { //logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count); IEnumerable 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>> GetCollectionMediaItems( List effectiveBlocks) { var collectionKeys = effectiveBlocks.Map(b => b.Block.Items) .Flatten() .DistinctBy(i => i.Id) .Map(CollectionKey.ForBlockItem) .Distinct() .ToList(); IEnumerable>> tuples = await collectionKeys.Map( async collectionKey => Tuple( collectionKey, await MediaItemsForCollection.Collect( mediaCollectionRepository, televisionRepository, artistRepository, collectionKey))).SequenceParallel(); return LanguageExt.Map.createRange(tuples); } private Map GetCollectionEtags( Map> collectionMediaItems) { var result = new Map(); foreach ((CollectionKey key, List items) in collectionMediaItems) { result = result.Add(key, collectionEtag.ForCollectionItems(items)); } return result; } private static TimeSpan DurationForMediaItem(MediaItem mediaItem) { MediaVersion version = mediaItem.GetHeadVersion(); return version.Duration; } }