diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/ContentCreatePlaylist.cs b/ErsatzTV.Core/Api/ScriptedPlayout/ContentCreatePlaylist.cs new file mode 100644 index 000000000..9d988245b --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/ContentCreatePlaylist.cs @@ -0,0 +1,12 @@ +using System.ComponentModel; + +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record ContentCreatePlaylist +{ + [Description("Unique name used to reference this content throughout the scripted schedule")] + public string Key { get; set; } + + [Description("List of playlist items")] + public List Items { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/ControlPreRollOn.cs b/ErsatzTV.Core/Api/ScriptedPlayout/ControlPreRollOn.cs new file mode 100644 index 000000000..10739507c --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/ControlPreRollOn.cs @@ -0,0 +1,9 @@ +using System.ComponentModel; + +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record ControlPreRollOn +{ + [Description("The 'key' for the scripted playlist")] + public string Playlist { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/PlaylistItem.cs b/ErsatzTV.Core/Api/ScriptedPlayout/PlaylistItem.cs new file mode 100644 index 000000000..caa12689c --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/PlaylistItem.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record PlaylistItem +{ + [Description("The 'key' for the content")] + public string Content { get; set; } + + public int Count { get; set; } +} diff --git a/ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs b/ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs index ade0547b3..5984b6a4c 100644 --- a/ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs +++ b/ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs @@ -18,5 +18,6 @@ public enum ProgramScheduleItemCollectionType Image = 60, RemoteStream = 70, - FakeCollection = 100 + FakeCollection = 100, + FakePlaylistItem = 101 } diff --git a/ErsatzTV.Core/Scheduling/CollectionKey.cs b/ErsatzTV.Core/Scheduling/CollectionKey.cs index c2936e9be..f05184d9a 100644 --- a/ErsatzTV.Core/Scheduling/CollectionKey.cs +++ b/ErsatzTV.Core/Scheduling/CollectionKey.cs @@ -51,6 +51,11 @@ public class CollectionKey : Record { CollectionType = item.CollectionType }, + ProgramScheduleItemCollectionType.FakePlaylistItem => new CollectionKey + { + CollectionType = item.CollectionType, + CollectionId = item.CollectionId + }, ProgramScheduleItemCollectionType.Movie => new CollectionKey { CollectionType = item.CollectionType, diff --git a/ErsatzTV.Core/Scheduling/Engine/EnumeratorDetails.cs b/ErsatzTV.Core/Scheduling/Engine/EnumeratorDetails.cs new file mode 100644 index 000000000..86498d3ee --- /dev/null +++ b/ErsatzTV.Core/Scheduling/Engine/EnumeratorDetails.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Scheduling; + +namespace ErsatzTV.Core.Scheduling.Engine; + +public record EnumeratorDetails( + IMediaCollectionEnumerator Enumerator, + string HistoryKey, + PlaybackOrder PlaybackOrder); diff --git a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs index 01a874f4e..2962dd95d 100644 --- a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs +++ b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs @@ -42,6 +42,7 @@ public interface ISchedulingEngine CancellationToken cancellationToken); Task AddPlaylist(string key, string playlist, string playlistGroup, CancellationToken cancellationToken); + Task CreatePlaylist(string key, Dictionary playlistItems, CancellationToken cancellationToken); Task AddSmartCollection( string key, @@ -129,6 +130,9 @@ public interface ISchedulingEngine Task GraphicsOff(List graphicsElements, CancellationToken cancellationToken); Task WatermarkOn(List watermarks); Task WatermarkOff(List watermarks); + void PreRollOn(string content); + void PreRollOff(); + void SkipItems(string content, int count); void SkipToItem(string content, int season, int episode); ISchedulingEngine WaitUntil(TimeOnly waitUntil, bool tomorrow, bool rewindOnReset); diff --git a/ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs b/ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs index 1e82d47d2..ad72819f2 100644 --- a/ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs +++ b/ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs @@ -7,7 +7,7 @@ namespace ErsatzTV.Core.Scheduling.Engine; public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository) { - public async Task> GetEnumerator( + public async Task> GetEnumerator( Dictionary> guids, List searches, string groupBy, @@ -70,7 +70,7 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository shuffleGroups, cancellationToken); - return new MarathonContentResult( + return new PlaylistContentResult( enumerator, itemMap.ToImmutableDictionary(x => CollectionKey.ForPlaylistItem(x.Key), x => x.Value)); } diff --git a/ErsatzTV.Core/Scheduling/Engine/MarathonContentResult.cs b/ErsatzTV.Core/Scheduling/Engine/PlaylistContentResult.cs similarity index 85% rename from ErsatzTV.Core/Scheduling/Engine/MarathonContentResult.cs rename to ErsatzTV.Core/Scheduling/Engine/PlaylistContentResult.cs index 2a9e0013a..ec4de189f 100644 --- a/ErsatzTV.Core/Scheduling/Engine/MarathonContentResult.cs +++ b/ErsatzTV.Core/Scheduling/Engine/PlaylistContentResult.cs @@ -3,6 +3,6 @@ using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Scheduling.Engine; -public record MarathonContentResult( +public record PlaylistContentResult( PlaylistEnumerator PlaylistEnumerator, ImmutableDictionary> Content); diff --git a/ErsatzTV.Core/Scheduling/Engine/PlaylistHelper.cs b/ErsatzTV.Core/Scheduling/Engine/PlaylistHelper.cs new file mode 100644 index 000000000..00b64f39c --- /dev/null +++ b/ErsatzTV.Core/Scheduling/Engine/PlaylistHelper.cs @@ -0,0 +1,66 @@ +using System.Collections.Immutable; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; + +namespace ErsatzTV.Core.Scheduling.Engine; + +public class PlaylistHelper(IMediaCollectionRepository mediaCollectionRepository) +{ + public async Task> GetEnumerator( + Dictionary enumerators, + Dictionary> enumeratorMediaItems, + Dictionary playlistItems, + CollectionEnumeratorState state, + CancellationToken cancellationToken) + { + Dictionary> itemMap = []; + + int playlistIndex = 0; + var allKeys = playlistItems.Keys.ToList(); + for (var index = 0; index < allKeys.Count; index++) + { + string key = allKeys[index]; + ImmutableList mediaItems = null; + if (!enumerators.TryGetValue(key, out EnumeratorDetails enumeratorDetails) || + !enumeratorMediaItems.TryGetValue(key, out mediaItems)) + { + Console.WriteLine($"Something is wrong with the playlist with key {key}"); + Console.WriteLine($"details: {(enumeratorDetails is null ? "null" : "not null")}"); + Console.WriteLine($"items: {(mediaItems?.Count ?? -1)}"); + + continue; + } + + int count = playlistItems[key]; + for (var i = 0; i < count; i++) + { + PlaylistItem playlistItem = new() + { + Index = playlistIndex, + + CollectionType = ProgramScheduleItemCollectionType.FakePlaylistItem, + CollectionId = playlistIndex, + + PlayAll = false, + PlaybackOrder = enumeratorDetails.PlaybackOrder, + + IncludeInProgramGuide = true + }; + + itemMap.Add(playlistItem, mediaItems.ToList()); + playlistIndex++; + } + } + + PlaylistEnumerator enumerator = await PlaylistEnumerator.Create( + mediaCollectionRepository, + itemMap, + state, + shufflePlaylistItems: false, + cancellationToken); + + return new PlaylistContentResult( + enumerator, + itemMap.ToImmutableDictionary(x => CollectionKey.ForPlaylistItem(x.Key), x => x.Value)); + } +} diff --git a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs index b135ae74b..3af7f2e1a 100644 --- a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs +++ b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs @@ -27,6 +27,7 @@ public class SchedulingEngine( private readonly Dictionary> _graphicsElementCache = new(); private readonly Dictionary> _watermarkCache = new(); private readonly Dictionary _enumerators = new(); + private readonly Dictionary> _enumeratorMediaItems = new(); private readonly SchedulingEngineState _state = new(0); private PlayoutReferenceData _referenceData; @@ -112,33 +113,36 @@ public class SchedulingEngine( PlaybackOrder playbackOrder, CancellationToken cancellationToken) { - if (!_enumerators.ContainsKey(key)) + if (_enumerators.ContainsKey(key)) { - int index = _enumerators.Count; - List items = - await mediaCollectionRepository.GetCollectionItemsByName(collectionName, cancellationToken); - if (items.Count == 0) + return; + } + + int index = _enumerators.Count; + List items = + await mediaCollectionRepository.GetCollectionItemsByName(collectionName, cancellationToken); + if (items.Count == 0) + { + logger.LogWarning("Skipping invalid or empty collection {Name}", collectionName); + return; + } + + _enumeratorMediaItems[key] = items.ToImmutableList(); + 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.LogWarning("Skipping invalid or empty collection {Name}", collectionName); - return; - } + logger.LogDebug( + "Added collection {Name} with key {Key} and order {Order}", + collectionName, + key, + playbackOrder); - 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 collection {Name} with key {Key} and order {Order}", - collectionName, - key, - playbackOrder); - - ApplyHistory(historyKey, items, enumerator, playbackOrder); - } + ApplyHistory(historyKey, items, enumerator, playbackOrder); } } } @@ -152,11 +156,16 @@ public class SchedulingEngine( PlaybackOrder itemPlaybackOrder, bool playAllItems) { + if (_enumerators.ContainsKey(key)) + { + return; + } + var helper = new MarathonHelper(mediaCollectionRepository); int index = _enumerators.Count; var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; - Option maybeResult = await helper.GetEnumerator( + Option maybeResult = await helper.GetEnumerator( guids, searches, groupBy, @@ -166,7 +175,7 @@ public class SchedulingEngine( state, CancellationToken.None); - foreach (MarathonContentResult result in maybeResult) + foreach (PlaylistContentResult result in maybeResult) { foreach (PlaylistEnumerator enumerator in Optional(result.PlaylistEnumerator)) { @@ -183,8 +192,6 @@ public class SchedulingEngine( } } } - - await Task.Delay(10); } public async Task AddMultiCollection( @@ -193,33 +200,36 @@ public class SchedulingEngine( PlaybackOrder playbackOrder, CancellationToken cancellationToken) { - if (!_enumerators.ContainsKey(key)) + if (_enumerators.ContainsKey(key)) { - int index = _enumerators.Count; - List items = - await mediaCollectionRepository.GetMultiCollectionItemsByName(multiCollectionName, cancellationToken); - if (items.Count == 0) + return; + } + + int index = _enumerators.Count; + List items = + await mediaCollectionRepository.GetMultiCollectionItemsByName(multiCollectionName, cancellationToken); + if (items.Count == 0) + { + logger.LogWarning("Skipping invalid or empty multi collection {Name}", multiCollectionName); + return; + } + + _enumeratorMediaItems[key] = items.ToImmutableList(); + 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.LogWarning("Skipping invalid or empty multi collection {Name}", multiCollectionName); - return; - } + logger.LogDebug( + "Added multi collection {Name} with key {Key} and order {Order}", + multiCollectionName, + key, + playbackOrder); - 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); - } + ApplyHistory(historyKey, items, enumerator, playbackOrder); } } } @@ -230,36 +240,78 @@ public class SchedulingEngine( string playlistGroup, CancellationToken cancellationToken) { - if (!_enumerators.ContainsKey(key)) + if (_enumerators.ContainsKey(key)) { - int index = _enumerators.Count; - Dictionary> itemMap = - await mediaCollectionRepository.GetPlaylistItemMap(playlistGroup, playlist, cancellationToken); + return; + } - var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; + int index = _enumerators.Count; + Dictionary> itemMap = + await mediaCollectionRepository.GetPlaylistItemMap(playlistGroup, playlist, cancellationToken); - var enumerator = await PlaylistEnumerator.Create( - mediaCollectionRepository, - itemMap, - state, - false, - CancellationToken.None); + var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; - string historyKey = HistoryDetails.KeyForSchedulingContent(key, PlaybackOrder.None); - var details = new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None); + var enumerator = await PlaylistEnumerator.Create( + mediaCollectionRepository, + itemMap, + state, + false, + CancellationToken.None); - if (_enumerators.TryAdd(key, details)) + 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 CreatePlaylist( + string key, + Dictionary playlistItems, + CancellationToken cancellationToken) + { + if (_enumerators.ContainsKey(key)) + { + return; + } + + var helper = new PlaylistHelper(mediaCollectionRepository); + + int index = _enumerators.Count; + var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 }; + Option maybeResult = await helper.GetEnumerator( + _enumerators, + _enumeratorMediaItems, + playlistItems, + state, + CancellationToken.None); + + foreach (PlaylistContentResult result in maybeResult) + { + foreach (PlaylistEnumerator enumerator in Optional(result.PlaylistEnumerator)) { - logger.LogDebug( - "Added playlist {Group} / {Name} with key {Key}", - playlistGroup, - playlist, - key); + string historyKey = HistoryDetails.KeyForSchedulingPlaylistContent(key); + var details = new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None); - ApplyPlaylistHistory( - historyKey, - itemMap.ToImmutableDictionary(m => CollectionKey.ForPlaylistItem(m.Key), m => m.Value), - enumerator); + if (_enumerators.TryAdd(key, details)) + { + logger.LogDebug("Created playlist with key {Key}", key); + ApplyPlaylistHistory( + historyKey, + result.Content, + enumerator); + } } } } @@ -270,33 +322,36 @@ public class SchedulingEngine( PlaybackOrder playbackOrder, CancellationToken cancellationToken) { - if (!_enumerators.ContainsKey(key)) + if (_enumerators.ContainsKey(key)) { - int index = _enumerators.Count; - List items = - await mediaCollectionRepository.GetSmartCollectionItemsByName(smartCollectionName, cancellationToken); - if (items.Count == 0) + return; + } + + int index = _enumerators.Count; + List items = + await mediaCollectionRepository.GetSmartCollectionItemsByName(smartCollectionName, cancellationToken); + if (items.Count == 0) + { + logger.LogWarning("Skipping invalid or empty smart collection {Name}", smartCollectionName); + return; + } + + _enumeratorMediaItems[key] = items.ToImmutableList(); + 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.LogWarning("Skipping invalid or empty smart collection {Name}", smartCollectionName); - return; - } + logger.LogDebug( + "Added smart collection {Name} with key {Key} and order {Order}", + smartCollectionName, + key, + playbackOrder); - 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); - } + ApplyHistory(historyKey, items, enumerator, playbackOrder); } } } @@ -307,33 +362,36 @@ public class SchedulingEngine( PlaybackOrder playbackOrder, CancellationToken cancellationToken) { - if (!_enumerators.ContainsKey(key)) + if (_enumerators.ContainsKey(key)) { - int index = _enumerators.Count; - List items = - await mediaCollectionRepository.GetSmartCollectionItems(query, string.Empty, cancellationToken); - if (items.Count == 0) + return; + } + + int index = _enumerators.Count; + List items = + await mediaCollectionRepository.GetSmartCollectionItems(query, string.Empty, cancellationToken); + if (items.Count == 0) + { + logger.LogWarning("Skipping invalid or empty search query {Query}", query); + return; + } + + _enumeratorMediaItems[key] = items.ToImmutableList(); + 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.LogWarning("Skipping invalid or empty search query {Query}", query); - return; - } + logger.LogDebug( + "Added search query {Query} with key {Key} and order {Order}", + query, + key, + playbackOrder); - 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 search query {Query} with key {Key} and order {Order}", - query, - key, - playbackOrder); - - ApplyHistory(historyKey, items, enumerator, playbackOrder); - } + ApplyHistory(historyKey, items, enumerator, playbackOrder); } } } @@ -343,33 +401,36 @@ public class SchedulingEngine( Dictionary guids, PlaybackOrder playbackOrder) { - if (!_enumerators.ContainsKey(key)) + 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) + return; + } + + 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; + } + + _enumeratorMediaItems[key] = items.ToImmutableList(); + 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.LogWarning("Skipping invalid or empty show with key {Key}", key); - return; - } + logger.LogDebug( + "Added show with key {Key} and order {Order}", + key, + playbackOrder); - 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); - } + ApplyHistory(historyKey, items, enumerator, playbackOrder); } } } @@ -648,18 +709,15 @@ public class SchedulingEngine( TimeSpan remainingToFill = targetTime - _state.CurrentTime; while (!done && enumeratorDetails.Enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero) { - // foreach (string preRollSequence in context.GetPreRollSequence()) - // { - // context.PushFillerKind(FillerKind.PreRoll); - // await executeSequence(preRollSequence); - // context.PopFillerKind(); - // - // remainingToFill = targetTime - context.CurrentTime; - // if (remainingToFill <= TimeSpan.Zero) - // { - // break; - // } - // } + foreach (string preRollPlaylist in _state.GetPreRollPlaylist()) + { + AddFillerPlaylist(preRollPlaylist, FillerKind.PreRoll); + remainingToFill = targetTime - _state.CurrentTime; + if (remainingToFill <= TimeSpan.Zero) + { + break; + } + } foreach (MediaItem mediaItem in enumeratorDetails.Enumerator.Current) { @@ -801,23 +859,46 @@ public class SchedulingEngine( return offlineTail ? targetTime : _state.CurrentTime; } + private void AddFillerPlaylist(string playlist, FillerKind fillerKind) + { + if (!_enumerators.TryGetValue(playlist, out EnumeratorDetails enumeratorDetails)) + { + logger.LogWarning("Skipping invalid filler playlist {Key}", playlist); + return; + } + + if (enumeratorDetails.Enumerator is PlaylistEnumerator playlistEnumerator) + { + int count = playlistEnumerator.CountForFiller; + AddCountInternal( + enumeratorDetails, + count, + fillerKind, + customTitle: null, + disableWatermarks: true, + disableFiller: true); + } + } + private bool AddCountInternal( EnumeratorDetails enumeratorDetails, int count, Option fillerKind, string customTitle, - bool disableWatermarks) + bool disableWatermarks, + bool disableFiller = false) { var result = false; for (var i = 0; i < count; i++) { - // foreach (string preRollSequence in context.GetPreRollSequence()) - // { - // context.PushFillerKind(FillerKind.PreRoll); - // await executeSequence(preRollSequence); - // context.PopFillerKind(); - // } + if (!disableFiller) + { + foreach (string preRollPlaylist in _state.GetPreRollPlaylist()) + { + AddFillerPlaylist(preRollPlaylist, FillerKind.PreRoll); + } + } foreach (MediaItem mediaItem in enumeratorDetails.Enumerator.Current) { @@ -978,6 +1059,10 @@ public class SchedulingEngine( } } + public void PreRollOn(string content) => _state.PreRollOn(content); + + public void PreRollOff() => _state.PreRollOff(); + public void SkipItems(string content, int count) { if (!_enumerators.TryGetValue(content, out EnumeratorDetails enumeratorDetails)) @@ -1365,11 +1450,6 @@ public class SchedulingEngine( return fillerKind; } - // foreach (FillerKind fillerKind in _state.GetFillerKind()) - // { - // return fillerKind; - // } - return FillerKind.None; } @@ -1422,7 +1502,8 @@ public class SchedulingEngine( public record SerializedState( int? GuideGroup, - bool? GuideGroupLocked); + bool? GuideGroupLocked, + string PreRollPlaylist); private class SchedulingEngineState(int guideGroup) : ISchedulingEngineState { @@ -1430,6 +1511,8 @@ public class SchedulingEngine( private bool _guideGroupLocked; private readonly Dictionary _graphicsElements = []; private readonly System.Collections.Generic.HashSet _channelWatermarkIds = []; + private readonly Stack _fillerKind = new(); + private Option _preRollPlaylist = Option.None; // track is_done calls when current_time has not advanced private DateTimeOffset _lastCheckedTime; @@ -1497,6 +1580,10 @@ public class SchedulingEngine( public void ClearChannelWatermarkIds() => _channelWatermarkIds.Clear(); public List GetChannelWatermarkIds() => _channelWatermarkIds.ToList(); + public void PreRollOn(string playlist) => _preRollPlaylist = playlist; + public void PreRollOff() => _preRollPlaylist = Option.None; + public Option GetPreRollPlaylist() => _preRollPlaylist; + // result public Option RemoveBefore { get; set; } public bool ClearItems { get; set; } @@ -1529,15 +1616,16 @@ public class SchedulingEngine( public string SerializeContext() { - // string preRollSequence = null; - // foreach (string sequence in _preRollSequence) - // { - // preRollSequence = sequence; - // } + string preRollPlaylist = null; + foreach (string playlist in _preRollPlaylist) + { + preRollPlaylist = playlist; + } var state = new SerializedState( _guideGroup, - _guideGroupLocked); + _guideGroupLocked, + preRollPlaylist); return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings); } @@ -1555,9 +1643,4 @@ public class SchedulingEngine( } } } - - private record EnumeratorDetails( - IMediaCollectionEnumerator Enumerator, - string HistoryKey, - PlaybackOrder PlaybackOrder); } diff --git a/ErsatzTV.Core/Scheduling/HistoryDetails.cs b/ErsatzTV.Core/Scheduling/HistoryDetails.cs index 0a314a26c..9251adbac 100644 --- a/ErsatzTV.Core/Scheduling/HistoryDetails.cs +++ b/ErsatzTV.Core/Scheduling/HistoryDetails.cs @@ -75,6 +75,17 @@ internal static class HistoryDetails return JsonConvert.SerializeObject(historyKey, Formatting.None, JsonSettings); } + public static string KeyForSchedulingPlaylistContent(string key) + { + var historyKey = new Dictionary + { + { "Key", key }, + { "Order", nameof(PlaybackOrder.None) } + }; + + return JsonConvert.SerializeObject(historyKey, Formatting.None, JsonSettings); + } + public static string KeyForCollectionKey(CollectionKey collectionKey) { dynamic key = new diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs index 4908bdd99..966924ca4 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs @@ -121,7 +121,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor itemPlaybackOrder = PlaybackOrder.Shuffle; } - Option maybeResult = await helper.GetEnumerator( + Option maybeResult = await helper.GetEnumerator( guids, marathon.Searches, marathon.GroupBy, @@ -131,7 +131,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor state, cancellationToken); - foreach (MarathonContentResult result in maybeResult) + foreach (PlaylistContentResult result in maybeResult) { foreach ((CollectionKey collectionKey, List mediaItems) in result.Content) { diff --git a/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs b/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs index a3b21e0df..187ec7c83 100644 --- a/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs +++ b/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs @@ -123,6 +123,29 @@ public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedP return Ok(); } + [HttpPost("create_playlist", Name="CreatePlaylist")] + [Tags("Scripted Content")] + [EndpointSummary("Create a playlist")] + public async Task CreatePlaylist( + [FromRoute] + Guid buildId, + [FromBody] + ContentCreatePlaylist request, + CancellationToken cancellationToken) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + await engine.CreatePlaylist( + request.Key, + request.Items.ToDictionary(i => i.Content, i => i.Count), + cancellationToken); + return Ok(); + } + [HttpPost("add_search", Name = "AddSearch")] [Tags("Scripted Content")] [EndpointSummary("Add a search query")] @@ -510,6 +533,36 @@ public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedP return Ok(); } + [HttpPost("pre_roll_on", Name = "PreRollOn")] + [Tags("Scripted Control")] + [EndpointSummary("Turn on pre-roll playlist")] + public IActionResult PreRollOn([FromRoute] Guid buildId, [FromBody] ControlPreRollOn request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + engine.PreRollOn(request.Playlist); + return Ok(); + } + + [HttpPost("pre_roll_off", Name = "PreRollOff")] + [Tags("Scripted Control")] + [EndpointSummary("Turn off pre-roll playlist")] + public IActionResult PreRollOff([FromRoute] Guid buildId) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + engine.PreRollOff(); + return Ok(); + } + [HttpPost("skip_items", Name = "SkipItems")] [Tags("Scripted Control")] [EndpointSummary("Skip a specific number of items")] diff --git a/ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json b/ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json index 8606796d8..1896a32c7 100644 --- a/ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json +++ b/ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json @@ -247,6 +247,56 @@ } } }, + "/api/scripted/playout/build/{buildId}/create_playlist": { + "post": { + "tags": [ + "Scripted Content" + ], + "summary": "Create a playlist", + "operationId": "CreatePlaylist", + "parameters": [ + { + "name": "buildId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ContentCreatePlaylist" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContentCreatePlaylist" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ContentCreatePlaylist" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ContentCreatePlaylist" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/scripted/playout/build/{buildId}/add_search": { "post": { "tags": [ @@ -1124,6 +1174,81 @@ } } }, + "/api/scripted/playout/build/{buildId}/pre_roll_on": { + "post": { + "tags": [ + "Scripted Control" + ], + "summary": "Turn on pre-roll playlist", + "operationId": "PreRollOn", + "parameters": [ + { + "name": "buildId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ControlPreRollOn" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ControlPreRollOn" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ControlPreRollOn" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ControlPreRollOn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/scripted/playout/build/{buildId}/pre_roll_off": { + "post": { + "tags": [ + "Scripted Control" + ], + "summary": "Turn off pre-roll playlist", + "operationId": "PreRollOff", + "parameters": [ + { + "name": "buildId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/scripted/playout/build/{buildId}/skip_items": { "post": { "tags": [ @@ -1404,6 +1529,24 @@ } } }, + "ContentCreatePlaylist": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Unique name used to reference this content throughout the scripted schedule", + "nullable": true + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistItem" + }, + "description": "List of playlist items", + "nullable": true + } + } + }, "ContentMarathon": { "type": "object", "properties": { @@ -1584,6 +1727,16 @@ } } }, + "ControlPreRollOn": { + "type": "object", + "properties": { + "playlist": { + "type": "string", + "description": "The 'key' for the scripted playlist", + "nullable": true + } + } + }, "ControlSkipItems": { "type": "object", "properties": { @@ -1700,6 +1853,20 @@ } } }, + "PlaylistItem": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The 'key' for the content", + "nullable": true + }, + "count": { + "type": "integer", + "format": "int32" + } + } + }, "PlayoutContext": { "type": "object", "properties": { diff --git a/ErsatzTV/wwwroot/openapi/scripted-schedule.json b/ErsatzTV/wwwroot/openapi/scripted-schedule.json index af3c4288e..fa736cc73 100644 --- a/ErsatzTV/wwwroot/openapi/scripted-schedule.json +++ b/ErsatzTV/wwwroot/openapi/scripted-schedule.json @@ -247,6 +247,56 @@ } } }, + "/api/scripted/playout/build/{buildId}/create_playlist": { + "post": { + "tags": [ + "ScriptedSchedule" + ], + "summary": "Create a playlist", + "operationId": "CreatePlaylist", + "parameters": [ + { + "name": "buildId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ContentCreatePlaylist" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContentCreatePlaylist" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ContentCreatePlaylist" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ContentCreatePlaylist" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/scripted/playout/build/{buildId}/add_search": { "post": { "tags": [ @@ -1124,6 +1174,81 @@ } } }, + "/api/scripted/playout/build/{buildId}/pre_roll_on": { + "post": { + "tags": [ + "ScriptedSchedule" + ], + "summary": "Turn on pre-roll playlist", + "operationId": "PreRollOn", + "parameters": [ + { + "name": "buildId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ControlPreRollOn" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ControlPreRollOn" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ControlPreRollOn" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ControlPreRollOn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/scripted/playout/build/{buildId}/pre_roll_off": { + "post": { + "tags": [ + "ScriptedSchedule" + ], + "summary": "Turn off pre-roll playlist", + "operationId": "PreRollOff", + "parameters": [ + { + "name": "buildId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/scripted/playout/build/{buildId}/skip_items": { "post": { "tags": [ @@ -1404,6 +1529,24 @@ } } }, + "ContentCreatePlaylist": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Unique name used to reference this content throughout the scripted schedule", + "nullable": true + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistItem" + }, + "description": "List of playlist items", + "nullable": true + } + } + }, "ContentMarathon": { "type": "object", "properties": { @@ -1584,6 +1727,16 @@ } } }, + "ControlPreRollOn": { + "type": "object", + "properties": { + "playlist": { + "type": "string", + "description": "The 'key' for the scripted playlist", + "nullable": true + } + } + }, "ControlSkipItems": { "type": "object", "properties": { @@ -1700,6 +1853,20 @@ } } }, + "PlaylistItem": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The 'key' for the content", + "nullable": true + }, + "count": { + "type": "integer", + "format": "int32" + } + } + }, "PlayoutContext": { "type": "object", "properties": {