diff --git a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs index a47122e88..b6ce89c3e 100644 --- a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs +++ b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs @@ -20,11 +20,25 @@ public interface ISchedulingEngine ISchedulingEngine RestoreOrReset(Option maybeAnchor); // content definitions - Task AddCollection(string key, string collectionName, PlaybackOrder playbackOrder); - Task AddSearch(string key, string query, PlaybackOrder playbackOrder); + Task AddCollection(string key, string collectionName, PlaybackOrder playbackOrder); + + Task AddMarathon( + string key, + Dictionary> guids, + List searches, + string groupBy, + bool shuffleGroups, + PlaybackOrder itemPlaybackOrder, + bool playAllItems); + + Task AddMultiCollection(string key, string multiCollectionName, PlaybackOrder playbackOrder); + Task AddPlaylist(string key, string playlist, string playlistGroup); + Task AddSmartCollection(string key, string smartCollectionName, PlaybackOrder playbackOrder); + Task AddSearch(string key, string query, PlaybackOrder playbackOrder); + Task AddShow(string key, Dictionary guids, PlaybackOrder playbackOrder); // content instructions - ISchedulingEngine AddCount( + bool AddCount( string content, int count, Option fillerKind, diff --git a/ErsatzTV.Core/Scheduling/Engine/MarathonContentResult.cs b/ErsatzTV.Core/Scheduling/Engine/MarathonContentResult.cs new file mode 100644 index 000000000..2a9e0013a --- /dev/null +++ b/ErsatzTV.Core/Scheduling/Engine/MarathonContentResult.cs @@ -0,0 +1,8 @@ +using System.Collections.Immutable; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Scheduling.Engine; + +public record MarathonContentResult( + PlaylistEnumerator PlaylistEnumerator, + ImmutableDictionary> Content); diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutMarathonHelper.cs b/ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs similarity index 79% rename from ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutMarathonHelper.cs rename to ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs index 4068c6d7c..dcb3cdaeb 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutMarathonHelper.cs +++ b/ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs @@ -2,32 +2,31 @@ using System.Collections.Immutable; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Repositories; -using ErsatzTV.Core.Scheduling.YamlScheduling.Models; -namespace ErsatzTV.Core.Scheduling.YamlScheduling; +namespace ErsatzTV.Core.Scheduling.Engine; -public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectionRepository) +public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository) { - public async Task> GetEnumerator( - YamlPlayoutContentMarathonItem marathon, + public async Task> GetEnumerator( + Dictionary> guids, + List searches, + string groupBy, + bool shuffleGroups, + PlaybackOrder itemPlaybackOrder, + bool playAllItems, CollectionEnumeratorState state, CancellationToken cancellationToken) { - if (!Enum.TryParse(marathon.ItemOrder, true, out PlaybackOrder playbackOrder)) - { - playbackOrder = PlaybackOrder.Shuffle; - } - var allMediaItems = new List(); // grab items from each show guid - foreach (string showGuid in marathon.Guids.Map(g => $"{g.Source}://{g.Value}")) + foreach (string showGuid in guids.SelectMany(g => g.Value.Select(v => $"{g.Key}://{v}"))) { allMediaItems.AddRange(await mediaCollectionRepository.GetShowItemsByShowGuids([showGuid])); } // grab items from each search - foreach (string query in marathon.Searches) + foreach (string query in searches) { allMediaItems.AddRange(await mediaCollectionRepository.GetSmartCollectionItems(query, string.Empty)); } @@ -35,22 +34,22 @@ public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectio List> groups = []; // group by show - if (string.Equals(marathon.GroupBy, "show", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(groupBy, "show", StringComparison.OrdinalIgnoreCase)) { groups.AddRange(allMediaItems.GroupBy(MediaItemKeyByShow)); } // group by season - else if (string.Equals(marathon.GroupBy, "season", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(groupBy, "season", StringComparison.OrdinalIgnoreCase)) { groups.AddRange(allMediaItems.GroupBy(MediaItemKeyBySeason)); } // group by artist - else if (string.Equals(marathon.GroupBy, "artist", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(groupBy, "artist", StringComparison.OrdinalIgnoreCase)) { groups.AddRange(allMediaItems.GroupBy(MediaItemKeyByArtist)); } // group by album - else if (string.Equals(marathon.GroupBy, "album", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(groupBy, "album", StringComparison.OrdinalIgnoreCase)) { groups.AddRange(allMediaItems.GroupBy(MediaItemKeyByAlbum)); } @@ -60,7 +59,7 @@ public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectio for (var index = 0; index < groups.Count; index++) { IGrouping group = groups[index]; - PlaylistItem playlistItem = GroupToPlaylistItem(index, marathon, playbackOrder, group); + PlaylistItem playlistItem = GroupToPlaylistItem(index, playAllItems, itemPlaybackOrder, group); itemMap.Add(playlistItem, group.ToList()); } @@ -68,10 +67,10 @@ public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectio mediaCollectionRepository, itemMap, state, - marathon.ShuffleGroups, + shuffleGroups, cancellationToken); - return new YamlMarathonContentResult( + return new MarathonContentResult( enumerator, itemMap.ToImmutableDictionary(x => CollectionKey.ForPlaylistItem(x.Key), x => x.Value)); } @@ -133,7 +132,7 @@ public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectio private static PlaylistItem GroupToPlaylistItem( int index, - YamlPlayoutContentMarathonItem marathon, + bool playAllItems, PlaybackOrder playbackOrder, IGrouping group) => new() @@ -146,7 +145,7 @@ public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectio SmartCollectionId = group.Key.SmartCollectionId, MediaItemId = group.Key.MediaItemId, - PlayAll = marathon.PlayAllItems, + PlayAll = playAllItems, PlaybackOrder = playbackOrder, IncludeInProgramGuide = true diff --git a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs index acc7d37b1..6f1fa4afb 100644 --- a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs +++ b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Scheduling; @@ -98,7 +99,7 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito return this; } - public async Task AddCollection(string key, string collectionName, PlaybackOrder playbackOrder) + public async Task AddCollection(string key, string collectionName, PlaybackOrder playbackOrder) { if (!_enumerators.ContainsKey(key)) { @@ -107,7 +108,7 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito if (items.Count == 0) { logger.LogWarning("Skipping invalid or empty collection {Name}", collectionName); - return this; + return; } var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; @@ -128,11 +129,159 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito } } } - - return this; } - public async Task AddSearch(string key, string query, PlaybackOrder playbackOrder) + public async Task AddMarathon( + string key, + Dictionary> guids, + List searches, + string groupBy, + bool shuffleGroups, + PlaybackOrder itemPlaybackOrder, + bool playAllItems) + { + var helper = new MarathonHelper(mediaCollectionRepository); + + int index = _enumerators.Count; + var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; + Option maybeResult = await helper.GetEnumerator( + guids, + searches, + groupBy, + shuffleGroups, + itemPlaybackOrder, + playAllItems, + state, + CancellationToken.None); + + foreach (MarathonContentResult result in maybeResult) + { + foreach (PlaylistEnumerator enumerator in Optional(result.PlaylistEnumerator)) + { + string historyKey = HistoryDetails.KeyForSchedulingMarathonContent(key, itemPlaybackOrder, groupBy); + var details = new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None); + + if (_enumerators.TryAdd(key, details)) + { + logger.LogDebug("Added marathon with key {Key}", key); + ApplyPlaylistHistory( + historyKey, + result.Content, + enumerator); + } + } + } + + await Task.Delay(10); + } + + public async Task AddMultiCollection( + string key, + string multiCollectionName, + PlaybackOrder playbackOrder) + { + if (!_enumerators.ContainsKey(key)) + { + int index = _enumerators.Count; + List items = await mediaCollectionRepository.GetMultiCollectionItemsByName(multiCollectionName); + if (items.Count == 0) + { + logger.LogWarning("Skipping invalid or empty multi collection {Name}", multiCollectionName); + return; + } + + var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; + foreach (var enumerator in EnumeratorForContent(items, state, playbackOrder)) + { + string historyKey = HistoryDetails.KeyForSchedulingContent(key, playbackOrder); + var details = new EnumeratorDetails(enumerator, historyKey, playbackOrder); + + if (_enumerators.TryAdd(key, details)) + { + logger.LogDebug( + "Added multi collection {Name} with key {Key} and order {Order}", + multiCollectionName, + key, + playbackOrder); + + ApplyHistory(historyKey, items, enumerator, playbackOrder); + } + } + } + } + + public async Task AddPlaylist(string key, string playlist, string playlistGroup) + { + if (!_enumerators.ContainsKey(key)) + { + int index = _enumerators.Count; + Dictionary> itemMap = + await mediaCollectionRepository.GetPlaylistItemMap(playlistGroup, playlist); + + var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; + + var enumerator = await PlaylistEnumerator.Create( + mediaCollectionRepository, + itemMap, + state, + false, + CancellationToken.None); + + string historyKey = HistoryDetails.KeyForSchedulingContent(key, PlaybackOrder.None); + var details = new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None); + + if (_enumerators.TryAdd(key, details)) + { + logger.LogDebug( + "Added playlist {Group} / {Name} with key {Key}", + playlistGroup, + playlist, + key); + + ApplyPlaylistHistory( + historyKey, + itemMap.ToImmutableDictionary(m => CollectionKey.ForPlaylistItem(m.Key), m => m.Value), + enumerator); + } + } + } + + public async Task AddSmartCollection( + string key, + string smartCollectionName, + PlaybackOrder playbackOrder) + { + if (!_enumerators.ContainsKey(key)) + { + int index = _enumerators.Count; + List items = await mediaCollectionRepository.GetSmartCollectionItemsByName(smartCollectionName); + if (items.Count == 0) + { + logger.LogWarning("Skipping invalid or empty smart collection {Name}", smartCollectionName); + return; + } + + var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; + foreach (var enumerator in EnumeratorForContent(items, state, playbackOrder)) + { + string historyKey = HistoryDetails.KeyForSchedulingContent(key, playbackOrder); + var details = new EnumeratorDetails(enumerator, historyKey, playbackOrder); + + if (_enumerators.TryAdd(key, details)) + { + logger.LogDebug( + "Added smart collection {Name} with key {Key} and order {Order}", + smartCollectionName, + key, + playbackOrder); + + ApplyHistory(historyKey, items, enumerator, playbackOrder); + } + } + } + } + + public async Task AddSearch(string key, string query, PlaybackOrder playbackOrder) { if (!_enumerators.ContainsKey(key)) { @@ -141,7 +290,7 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito if (items.Count == 0) { logger.LogWarning("Skipping invalid or empty search query {Query}", query); - return this; + return; } var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; @@ -162,11 +311,45 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito } } } - - return this; } - public ISchedulingEngine AddCount( + public async Task AddShow( + string key, + Dictionary guids, + PlaybackOrder playbackOrder) + { + if (!_enumerators.ContainsKey(key)) + { + int index = _enumerators.Count; + List items = + await mediaCollectionRepository.GetShowItemsByShowGuids( + guids.Map(g => $"{g.Key}://{g.Value}").ToList()); + if (items.Count == 0) + { + logger.LogWarning("Skipping invalid or empty show with key {Key}", key); + return; + } + + var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; + foreach (var enumerator in EnumeratorForContent(items, state, playbackOrder)) + { + string historyKey = HistoryDetails.KeyForSchedulingContent(key, playbackOrder); + var details = new EnumeratorDetails(enumerator, historyKey, playbackOrder); + + if (_enumerators.TryAdd(key, details)) + { + logger.LogDebug( + "Added show with key {Key} and order {Order}", + key, + playbackOrder); + + ApplyHistory(historyKey, items, enumerator, playbackOrder); + } + } + } + } + + public bool AddCount( string content, int count, Option fillerKind, @@ -176,9 +359,11 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito if (!_enumerators.TryGetValue(content, out EnumeratorDetails enumeratorDetails)) { logger.LogWarning("Skipping invalid content {Key}", content); - return this; + return false; } + var result = false; + for (var i = 0; i < count; i++) { // foreach (string preRollSequence in context.GetPreRollSequence()) @@ -244,6 +429,8 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito } enumeratorDetails.Enumerator.MoveNext(); + + result = true; } // foreach (string postRollSequence in context.GetPostRollSequence()) @@ -254,7 +441,7 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito // } } - return this; + return result; } public ISchedulingEngine WaitUntil(TimeOnly waitUntil, bool tomorrow, bool rewindOnReset) @@ -330,6 +517,106 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito return Option.None; } + private void ApplyPlaylistHistory( + string historyKey, + ImmutableDictionary> itemMap, + PlaylistEnumerator playlistEnumerator) + { + DateTime historyTime = _state.CurrentTime.UtcDateTime; + + var filteredHistory = _referenceData.PlayoutHistory.ToList(); + filteredHistory.RemoveAll(h => _state.HistoryToRemove.Contains(h.Id)); + + Option maxWhen = filteredHistory + .Filter(h => h.Key == historyKey) + .Filter(h => h.When < historyTime) + .Map(h => h.When) + .OrderByDescending(h => h) + .HeadOrNone() + .IfNone(DateTime.MinValue); + + var maybeHistory = filteredHistory + .Filter(h => h.Key == historyKey) + .Filter(h => h.When == maxWhen) + .ToList(); + + Option maybePrimaryHistory = maybeHistory + .Filter(h => string.IsNullOrWhiteSpace(h.ChildKey)) + .HeadOrNone(); + + foreach (PlayoutHistory primaryHistory in maybePrimaryHistory) + { + var hasSetEnumeratorIndex = false; + + var childEnumeratorKeys = playlistEnumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList(); + foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in + playlistEnumerator.ChildEnumerators) + { + PlaybackOrder itemPlaybackOrder = childEnumerator switch + { + ChronologicalMediaCollectionEnumerator => PlaybackOrder.Chronological, + RandomizedMediaCollectionEnumerator => PlaybackOrder.Random, + ShuffledMediaCollectionEnumerator => PlaybackOrder.Shuffle, + _ => PlaybackOrder.None + }; + + Option maybeApplicableHistory = maybeHistory + .Filter(h => h.ChildKey == HistoryDetails.KeyForCollectionKey(collectionKey)) + .HeadOrNone(); + + if (!itemMap.TryGetValue(collectionKey, out List collectionItems) || + collectionItems.Count == 0) + { + continue; + } + + foreach (PlayoutHistory h in maybeApplicableHistory) + { + // logger.LogDebug( + // "History is applicable: {When}: {ChildKey} / {History} / {IsCurrentChild}", + // h.When, + // h.ChildKey, + // h.Details, + // h.IsCurrentChild); + + playlistEnumerator.ResetState( + new CollectionEnumeratorState + { + Seed = playlistEnumerator.State.Seed, + Index = h.Index + (h.IsCurrentChild ? 1 : 0) + }); + + if (itemPlaybackOrder is PlaybackOrder.Chronological) + { + HistoryDetails.MoveToNextItem( + collectionItems, + h.Details, + childEnumerator, + itemPlaybackOrder, + true); + } + + if (h.IsCurrentChild) + { + // try to find enumerator based on collection key + playlistEnumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey)); + hasSetEnumeratorIndex = true; + } + } + } + + if (!hasSetEnumeratorIndex) + { + // falling back to enumerator based on index + playlistEnumerator.SetEnumeratorIndex(primaryHistory.Index); + } + + // only move next at the end, because that may also move + // the enumerator index + playlistEnumerator.MoveNext(); + } + } + private void ApplyHistory( string historyKey, List collectionItems, @@ -354,106 +641,30 @@ public class SchedulingEngine(IMediaCollectionRepository mediaCollectionReposito .Filter(h => h.When == maxWhen) .ToList(); - if (enumerator is PlaylistEnumerator playlistEnumerator) + if (enumerator is PlaylistEnumerator) { - Option maybePrimaryHistory = maybeHistory - .Filter(h => string.IsNullOrWhiteSpace(h.ChildKey)) - .HeadOrNone(); - - foreach (PlayoutHistory primaryHistory in maybePrimaryHistory) - { - var hasSetEnumeratorIndex = false; - - var childEnumeratorKeys = playlistEnumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList(); - foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in - playlistEnumerator.ChildEnumerators) - { - PlaybackOrder itemPlaybackOrder = childEnumerator switch - { - ChronologicalMediaCollectionEnumerator => PlaybackOrder.Chronological, - RandomizedMediaCollectionEnumerator => PlaybackOrder.Random, - ShuffledMediaCollectionEnumerator => PlaybackOrder.Shuffle, - _ => PlaybackOrder.None - }; - - Option maybeApplicableHistory = maybeHistory - .Filter(h => h.ChildKey == HistoryDetails.KeyForCollectionKey(collectionKey)) - .HeadOrNone(); - - if (collectionItems.Count == 0) - { - continue; - } - - foreach (PlayoutHistory h in maybeApplicableHistory) - { - // logger.LogDebug( - // "History is applicable: {When}: {ChildKey} / {History} / {IsCurrentChild}", - // h.When, - // h.ChildKey, - // h.Details, - // h.IsCurrentChild); - - enumerator.ResetState( - new CollectionEnumeratorState - { - Seed = enumerator.State.Seed, - Index = h.Index + (h.IsCurrentChild ? 1 : 0) - }); - - if (itemPlaybackOrder is PlaybackOrder.Chronological) - { - HistoryDetails.MoveToNextItem( - collectionItems, - h.Details, - childEnumerator, - itemPlaybackOrder, - true); - } - - if (h.IsCurrentChild) - { - // try to find enumerator based on collection key - playlistEnumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey)); - hasSetEnumeratorIndex = true; - } - } - } - - if (!hasSetEnumeratorIndex) - { - // falling back to enumerator based on index - playlistEnumerator.SetEnumeratorIndex(primaryHistory.Index); - } - - // only move next at the end, because that may also move - // the enumerator index - playlistEnumerator.MoveNext(); - } + return; } - else + + if (collectionItems.Count == 0) { - if (collectionItems.Count == 0) + return; + } + + // seek to the appropriate place in the collection enumerator + foreach (PlayoutHistory h in maybeHistory) + { + // logger.LogDebug("History is applicable: {When}: {History}", h.When, h.Details); + + enumerator.ResetState(new CollectionEnumeratorState { Seed = enumerator.State.Seed, Index = h.Index + 1 }); + + if (playbackOrder is PlaybackOrder.Chronological) { - return; - } - - // seek to the appropriate place in the collection enumerator - foreach (PlayoutHistory h in maybeHistory) - { - // logger.LogDebug("History is applicable: {When}: {History}", h.When, h.Details); - - enumerator.ResetState( - new CollectionEnumeratorState { Seed = enumerator.State.Seed, Index = h.Index + 1 }); - - if (playbackOrder is PlaybackOrder.Chronological) - { - HistoryDetails.MoveToNextItem( - collectionItems, - h.Details, - enumerator, - playbackOrder); - } + HistoryDetails.MoveToNextItem( + collectionItems, + h.Details, + enumerator, + playbackOrder); } } } diff --git a/ErsatzTV.Core/Scheduling/HistoryDetails.cs b/ErsatzTV.Core/Scheduling/HistoryDetails.cs index 62f9211e6..0a314a26c 100644 --- a/ErsatzTV.Core/Scheduling/HistoryDetails.cs +++ b/ErsatzTV.Core/Scheduling/HistoryDetails.cs @@ -54,7 +54,22 @@ internal static class HistoryDetails var historyKey = new Dictionary { { "Key", key }, - { "Order", playbackOrder.ToString().ToLowerInvariant() } + { "Order", playbackOrder.ToString() } + }; + + return JsonConvert.SerializeObject(historyKey, Formatting.None, JsonSettings); + } + + public static string KeyForSchedulingMarathonContent(string key, PlaybackOrder itemPlaybackOrder, string groupBy) + { + var historyKey = new Dictionary + { + { "Key", key }, + { "Order", nameof(PlaybackOrder.None) }, + + // we need to ignore history when any of these properties change + { "ItemOrder", itemPlaybackOrder.ToString() }, + { "GroupBy", groupBy } }; return JsonConvert.SerializeObject(historyKey, Formatting.None, JsonSettings); diff --git a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs index f0990e06e..ca598f69a 100644 --- a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs +++ b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs @@ -1,34 +1,117 @@ using System.Diagnostics.CodeAnalysis; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Scheduling.Engine; +using IronPython.Runtime; namespace ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules; [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")] [SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits")] +[SuppressMessage("ReSharper", "InconsistentNaming")] +[SuppressMessage("ReSharper", "UnusedMember.Global")] public class ContentModule(ISchedulingEngine schedulingEngine) { - public bool add_search(string key, string query, string order) + public void add_search(string key, string query, string order) { if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder)) { - return false; + return; } schedulingEngine.AddSearch(key, query, playbackOrder).GetAwaiter().GetResult(); - - return true; } - public bool add_collection(string key, string collection, string order) + public void add_collection(string key, string collection, string order) { if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder)) { - return false; + return; } schedulingEngine.AddCollection(key, collection, playbackOrder).GetAwaiter().GetResult(); + } - return true; + public void add_marathon( + string key, + string group_by, + string item_order = "shuffle", + PythonDictionary guids = null, + PythonList searches = null, + bool play_all_items = false, + bool shuffle_groups = false) + { + + if (!Enum.TryParse(item_order, ignoreCase: true, out PlaybackOrder itemPlaybackOrder)) + { + itemPlaybackOrder = PlaybackOrder.Shuffle; + } + + var mappedGuids = new Dictionary>(); + if (guids != null) + { + foreach (KeyValuePair guid in guids) + { + var guidKey = guid.Key.ToString(); + if (guidKey is not null && guid.Value is PythonList guidValues) + { + mappedGuids.Add(guidKey, guidValues.Select(x => x.ToString()).ToList()); + } + } + } + + var mappedSearches = new List(); + if (searches != null) + { + mappedSearches.AddRange(searches.Select(x => x.ToString())); + } + + // guids OR searches are required + if (mappedGuids.Count == 0 && mappedSearches.Count == 0) + { + return; + } + + schedulingEngine + .AddMarathon(key, mappedGuids, mappedSearches, group_by, shuffle_groups, itemPlaybackOrder, play_all_items) + .GetAwaiter() + .GetResult(); + } + + public void add_multi_collection(string key, string multi_collection, string order) + { + if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder)) + { + return; + } + + schedulingEngine.AddMultiCollection(key, multi_collection, playbackOrder).GetAwaiter().GetResult(); + } + + public void add_playlist(string key, string playlist, string playlist_group) + { + schedulingEngine.AddPlaylist(key, playlist, playlist_group).GetAwaiter().GetResult(); + } + + public void add_smart_collection(string key, string smart_collection, string order) + { + if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder)) + { + return; + } + + schedulingEngine.AddSmartCollection(key, smart_collection, playbackOrder).GetAwaiter().GetResult(); + } + + public void add_show(string key, PythonDictionary guids, string order) + { + if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder)) + { + return; + } + + schedulingEngine + .AddShow(key, guids.ToDictionary(k => k.Key.ToString(), k => k.Value.ToString()), playbackOrder) + .GetAwaiter() + .GetResult(); } } diff --git a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs index c8fbbcccb..187703da3 100644 --- a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs +++ b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs @@ -7,11 +7,19 @@ namespace ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules; [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")] [SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits")] [SuppressMessage("ReSharper", "InconsistentNaming")] +[SuppressMessage("ReSharper", "UnusedMember.Global")] public class PlayoutModule(ISchedulingEngine schedulingEngine) { + public int FailureCount { get; private set; } + // content instructions - public void add_count(string content, int count, string filler_kind = null, string custom_title = null, bool disable_watermarks = false) + public void add_count( + string content, + int count, + string filler_kind = null, + string custom_title = null, + bool disable_watermarks = false) { Option maybeFillerKind = Option.None; if (Enum.TryParse(filler_kind, ignoreCase: true, out FillerKind fillerKind)) @@ -19,7 +27,15 @@ public class PlayoutModule(ISchedulingEngine schedulingEngine) maybeFillerKind = fillerKind; } - schedulingEngine.AddCount(content, count, maybeFillerKind, custom_title, disable_watermarks); + bool success = schedulingEngine.AddCount(content, count, maybeFillerKind, custom_title, disable_watermarks); + if (success) + { + FailureCount = 0; + } + else + { + FailureCount++; + } } diff --git a/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs index 788f96ac0..ff7e7babc 100644 --- a/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs @@ -89,7 +89,7 @@ public class ScriptedPlayoutBuilder( // define content first engine.Operations.Invoke(defineContentFunc); - var context = new PythonPlayoutContext(schedulingEngine.GetState(), engine); + var context = new PythonPlayoutContext(schedulingEngine.GetState(), playoutModule, engine); // reset if applicable if (mode is PlayoutBuildMode.Reset && resetPlayoutFunc != null) @@ -134,12 +134,18 @@ public class ScriptedPlayoutBuilder( public class PythonPlayoutContext { private readonly ISchedulingEngineState _state; + private readonly PlayoutModule _playoutModule; private readonly ScriptEngine _scriptEngine; private readonly dynamic _datetimeModule; + private const int MaxFailures = 10; - public PythonPlayoutContext(ISchedulingEngineState state, ScriptEngine scriptEngine) + public PythonPlayoutContext( + ISchedulingEngineState state, + PlayoutModule playoutModule, + ScriptEngine scriptEngine) { _state = state; + _playoutModule = playoutModule; _scriptEngine = scriptEngine; _datetimeModule = _scriptEngine.ImportModule("datetime"); } @@ -147,7 +153,17 @@ public class ScriptedPlayoutBuilder( public object current_time => ToPythonDateTime(_state.CurrentTime); //public object finish => ToPythonDateTime(_state.Finish); - public bool is_done() => _state.CurrentTime >= _state.Finish; + public bool is_done() + { + if (_playoutModule.FailureCount >= MaxFailures) + { + throw new InvalidOperationException( + $"Script execution halted after {MaxFailures} consecutive failures to add content." + ); + } + + return _state.CurrentTime >= _state.Finish; + } private object ToPythonDateTime(DateTimeOffset dto) { diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs index e9a60d24e..dc47872d1 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs @@ -2,6 +2,7 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Scheduling.BlockScheduling; +using ErsatzTV.Core.Scheduling.Engine; using ErsatzTV.Core.Scheduling.YamlScheduling.Models; using Microsoft.Extensions.Logging; @@ -96,19 +97,42 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor // marathon is a special case that needs to be handled on its own if (content is YamlPlayoutContentMarathonItem marathon) { - var helper = new YamlPlayoutMarathonHelper(mediaCollectionRepository); - Option maybeResult = await helper.GetEnumerator( - marathon, + var helper = new MarathonHelper(mediaCollectionRepository); + var guids = new Dictionary>(); + foreach (var guid in marathon.Guids) + { + if (!guids.TryGetValue(guid.Source, out List value)) + { + value = []; + guids.Add(guid.Source, value); + } + + value.Add(guid.Value); + } + + if (!Enum.TryParse(marathon.ItemOrder, true, out PlaybackOrder itemPlaybackOrder)) + { + itemPlaybackOrder = PlaybackOrder.Shuffle; + } + + Option maybeResult = await helper.GetEnumerator( + guids, + marathon.Searches, + marathon.GroupBy, + marathon.ShuffleGroups, + itemPlaybackOrder, + marathon.PlayAllItems, state, cancellationToken); - foreach (YamlMarathonContentResult result in maybeResult) + + foreach (MarathonContentResult result in maybeResult) { foreach ((CollectionKey collectionKey, List mediaItems) in result.Content) { _playlistMediaItems.Add(new PlaylistKey(contentKey, collectionKey), mediaItems); } - return Some(result.PlaylistEnumerator); + return result.PlaylistEnumerator; } } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlMarathonContentResult.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/YamlMarathonContentResult.cs deleted file mode 100644 index a7626b1cc..000000000 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/YamlMarathonContentResult.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Immutable; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Scheduling; - -namespace ErsatzTV.Core.Scheduling.YamlScheduling; - -public record YamlMarathonContentResult( - IMediaCollectionEnumerator PlaylistEnumerator, - ImmutableDictionary> Content);