diff --git a/CHANGELOG.md b/CHANGELOG.md index 1defbadff..c0b5d547e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `Split Time Evenly` - default (existing) behavior; block time is split among all items that are visible in the EPG - `Use Actual Times` - actual times are used for all items that are visible in the EPG - This will introduce EPG gaps when filler is used, or when items are hidden from the EPG +- Add *experimental* `Scripted Schedule` playout system + - This system uses python scripts to support the highest degree of customization + - The goal is to expose methods equivalent to all sequential schedule (YAML) instructions ### Fix - Fix database operations that were slowing down playout builds @@ -89,6 +92,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `Classic Schedules` - `Block Schedules` - `Sequential Schedules` (formerly `YAML Schedules` or `YAML Playouts`) + - `Scripted Schedules` - `JSON (dizqueTV) Schedules` (formerly `External JSON Playouts`) - Allow multiple watermarks in playback troubleshooting - Classic schedules: allow selecting multiple watermarks on schedule items diff --git a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs new file mode 100644 index 000000000..a47122e88 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs @@ -0,0 +1,42 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Core.Scheduling.Engine; + +public interface ISchedulingEngine +{ + ISchedulingEngine WithPlayoutId(int playoutId); + + ISchedulingEngine WithMode(PlayoutBuildMode mode); + + ISchedulingEngine WithSeed(int seed); + + ISchedulingEngine WithReferenceData(PlayoutReferenceData referenceData); + + ISchedulingEngine BuildBetween(DateTimeOffset start, DateTimeOffset finish); + + ISchedulingEngine RemoveBefore(DateTimeOffset removeBefore); + + ISchedulingEngine RestoreOrReset(Option maybeAnchor); + + // content definitions + Task AddCollection(string key, string collectionName, PlaybackOrder playbackOrder); + Task AddSearch(string key, string query, PlaybackOrder playbackOrder); + + // content instructions + ISchedulingEngine AddCount( + string content, + int count, + Option fillerKind, + string customTitle, + bool disableWatermarks); + + // control instructions + ISchedulingEngine WaitUntil(TimeOnly waitUntil, bool tomorrow, bool rewindOnReset); + + + + PlayoutAnchor GetAnchor(); + + ISchedulingEngineState GetState(); +} diff --git a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs new file mode 100644 index 000000000..b0f2bc3ab --- /dev/null +++ b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs @@ -0,0 +1,21 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; + +namespace ErsatzTV.Core.Scheduling.Engine; + +public interface ISchedulingEngineState +{ + // state + int PlayoutId { get; } + PlayoutBuildMode Mode { get; } + int Seed { get; } + DateTimeOffset CurrentTime { get; } + DateTimeOffset Finish { get; } + + // result + Option RemoveBefore { get; } + bool ClearItems { get; } + List AddedItems { get; } + System.Collections.Generic.HashSet HistoryToRemove { get; } + List AddedHistory { get; } +} diff --git a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs new file mode 100644 index 000000000..acc7d37b1 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs @@ -0,0 +1,655 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling.BlockScheduling; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace ErsatzTV.Core.Scheduling.Engine; + +public class SchedulingEngine(IMediaCollectionRepository mediaCollectionRepository, ILogger logger) + : ISchedulingEngine +{ + private static readonly JsonSerializerSettings JsonSettings = new() + { + NullValueHandling = NullValueHandling.Ignore + }; + + private readonly Dictionary _enumerators = new(); + private readonly SchedulingEngineState _state = new(0); + private PlayoutReferenceData _referenceData; + + public ISchedulingEngine WithPlayoutId(int playoutId) + { + _state.PlayoutId = playoutId; + return this; + } + + public ISchedulingEngine WithMode(PlayoutBuildMode mode) + { + _state.Mode = mode; + return this; + } + + public ISchedulingEngine WithSeed(int seed) + { + _state.Seed = seed; + return this; + } + + public ISchedulingEngine WithReferenceData(PlayoutReferenceData referenceData) + { + _referenceData = referenceData; + return this; + } + + public ISchedulingEngine BuildBetween(DateTimeOffset start, DateTimeOffset finish) + { + _state.Start = start; + _state.Finish = finish; + _state.CurrentTime = start; + return this; + } + + public ISchedulingEngine RemoveBefore(DateTimeOffset removeBefore) + { + _state.RemoveBefore = removeBefore; + return this; + } + + public ISchedulingEngine RestoreOrReset(Option maybeAnchor) + { + if (_state.Mode is PlayoutBuildMode.Reset) + { + // erase items, not history + _state.ClearItems = true; + + // remove any future or "currently active" history items + // this prevents "walking" the playout forward by repeatedly resetting + var toRemove = new List(); + toRemove.AddRange( + _referenceData.PlayoutHistory.Filter(h => + h.When > _state.Start.UtcDateTime || + h.When <= _state.Start.UtcDateTime && h.Finish >= _state.Start.UtcDateTime)); + foreach (PlayoutHistory history in toRemove) + { + _state.HistoryToRemove.Add(history.Id); + } + } + else + { + foreach (PlayoutAnchor anchor in maybeAnchor) + { + _state.CurrentTime = new DateTimeOffset(anchor.NextStart.ToLocalTime(), _state.CurrentTime.Offset); + + if (string.IsNullOrWhiteSpace(anchor.Context)) + { + break; + } + + SerializedState state = JsonConvert.DeserializeObject(anchor.Context); + _state.LoadContext(state); + } + } + + return this; + } + + public async Task AddCollection(string key, string collectionName, PlaybackOrder playbackOrder) + { + if (!_enumerators.ContainsKey(key)) + { + int index = _enumerators.Count; + List items = await mediaCollectionRepository.GetCollectionItemsByName(collectionName); + if (items.Count == 0) + { + logger.LogWarning("Skipping invalid or empty collection {Name}", collectionName); + return this; + } + + 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); + } + } + } + + return this; + } + + public async Task AddSearch(string key, string query, PlaybackOrder playbackOrder) + { + if (!_enumerators.ContainsKey(key)) + { + int index = _enumerators.Count; + List items = await mediaCollectionRepository.GetSmartCollectionItems(query, string.Empty); + if (items.Count == 0) + { + logger.LogWarning("Skipping invalid or empty search query {Query}", query); + return this; + } + + 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); + } + } + } + + return this; + } + + public ISchedulingEngine AddCount( + string content, + int count, + Option fillerKind, + string customTitle, + bool disableWatermarks) + { + if (!_enumerators.TryGetValue(content, out EnumeratorDetails enumeratorDetails)) + { + logger.LogWarning("Skipping invalid content {Key}", content); + return this; + } + + for (var i = 0; i < count; i++) + { + // foreach (string preRollSequence in context.GetPreRollSequence()) + // { + // context.PushFillerKind(FillerKind.PreRoll); + // await executeSequence(preRollSequence); + // context.PopFillerKind(); + // } + + foreach (MediaItem mediaItem in enumeratorDetails.Enumerator.Current) + { + TimeSpan itemDuration = DurationForMediaItem(mediaItem); + + // create a playout item + var playoutItem = new PlayoutItem + { + PlayoutId = _state.PlayoutId, + MediaItemId = mediaItem.Id, + Start = _state.CurrentTime.UtcDateTime, + Finish = _state.CurrentTime.UtcDateTime + itemDuration, + InPoint = TimeSpan.Zero, + OutPoint = itemDuration, + FillerKind = GetFillerKind(fillerKind), + CustomTitle = string.IsNullOrWhiteSpace(customTitle) ? null : customTitle, + DisableWatermarks = disableWatermarks, + GuideGroup = _state.PeekNextGuideGroup(), + PlayoutItemWatermarks = [], + PlayoutItemGraphicsElements = [] + }; + + // foreach (int watermarkId in context.GetChannelWatermarkIds()) + // { + // playoutItem.PlayoutItemWatermarks.Add( + // new PlayoutItemWatermark + // { + // PlayoutItem = playoutItem, + // WatermarkId = watermarkId + // }); + // } + // + // foreach ((int graphicsElementId, string variablesJson) in context.GetGraphicsElements()) + // { + // playoutItem.PlayoutItemGraphicsElements.Add( + // new PlayoutItemGraphicsElement + // { + // PlayoutItem = playoutItem, + // GraphicsElementId = graphicsElementId, + // Variables = variablesJson + // }); + // } + + //await AddItemAndMidRoll(context, playoutItem, mediaItem, executeSequence); + + _state.AddedItems.Add(playoutItem); + _state.CurrentTime += playoutItem.OutPoint - playoutItem.InPoint; + _state.AdvanceGuideGroup(); + + // create history record + List maybeHistory = GetHistoryForItem(enumeratorDetails, playoutItem, mediaItem); + foreach (PlayoutHistory history in maybeHistory) + { + _state.AddedHistory.Add(history); + } + + enumeratorDetails.Enumerator.MoveNext(); + } + + // foreach (string postRollSequence in context.GetPostRollSequence()) + // { + // context.PushFillerKind(FillerKind.PostRoll); + // await executeSequence(postRollSequence); + // context.PopFillerKind(); + // } + } + + return this; + } + + public ISchedulingEngine WaitUntil(TimeOnly waitUntil, bool tomorrow, bool rewindOnReset) + { + var currentTime = _state.CurrentTime; + + var dayOnly = DateOnly.FromDateTime(currentTime.LocalDateTime); + var timeOnly = TimeOnly.FromDateTime(currentTime.LocalDateTime); + + if (timeOnly > waitUntil) + { + if (tomorrow) + { + // this is wrong when offset changes + dayOnly = dayOnly.AddDays(1); + currentTime = new DateTimeOffset(dayOnly, waitUntil, currentTime.Offset); + } + else if (rewindOnReset && _state.Mode == PlayoutBuildMode.Reset) + { + // maybe wrong when offset changes? + currentTime = new DateTimeOffset(dayOnly, waitUntil, currentTime.Offset); + } + } + else + { + // this is wrong when offset changes + currentTime = new DateTimeOffset(dayOnly, waitUntil, currentTime.Offset); + } + + _state.CurrentTime = currentTime; + + return this; + } + + public PlayoutAnchor GetAnchor() + { + DateTime maxTime = _state.CurrentTime.UtcDateTime; + if (_state.AddedItems.Count > 0) + { + maxTime = _state.AddedItems.Max(i => i.Finish); + } + + return new PlayoutAnchor + { + NextStart = maxTime, + Context = _state.SerializeContext() + }; + } + + public ISchedulingEngineState GetState() + { + return _state; + } + + private static Option EnumeratorForContent( + List items, + CollectionEnumeratorState state, + PlaybackOrder playbackOrder, + bool multiPart = false) + { + switch (playbackOrder) + { + case PlaybackOrder.Chronological: + return new ChronologicalMediaCollectionEnumerator(items, state); + case PlaybackOrder.Shuffle: + bool keepMultiPartEpisodesTogether = multiPart; + List groupedMediaItems = keepMultiPartEpisodesTogether + ? MultiPartEpisodeGrouper.GroupMediaItems(items, false) + : items.Map(mi => new GroupedMediaItem(mi, null)).ToList(); + return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state); + } + + return Option.None; + } + + private void ApplyHistory( + string historyKey, + List collectionItems, + IMediaCollectionEnumerator enumerator, + PlaybackOrder playbackOrder) + { + 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(); + + if (enumerator is PlaylistEnumerator 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(); + } + } + else + { + 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) + { + HistoryDetails.MoveToNextItem( + collectionItems, + h.Details, + enumerator, + playbackOrder); + } + } + } + } + + 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; + } + + private List GetHistoryForItem( + EnumeratorDetails enumeratorDetails, + PlayoutItem playoutItem, + MediaItem mediaItem) + { + var result = new List(); + + if (enumeratorDetails.Enumerator is PlaylistEnumerator playlistEnumerator) + { + // create a playout history record + var nextHistory = new PlayoutHistory + { + PlayoutId = _state.PlayoutId, + PlaybackOrder = enumeratorDetails.PlaybackOrder, + Index = playlistEnumerator.EnumeratorIndex, + When = playoutItem.StartOffset.UtcDateTime, + Finish = playoutItem.FinishOffset.UtcDateTime, + Key = enumeratorDetails.HistoryKey, + Details = HistoryDetails.ForMediaItem(mediaItem) + }; + + result.Add(nextHistory); + + for (var i = 0; i < playlistEnumerator.ChildEnumerators.Count; i++) + { + (IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) = + playlistEnumerator.ChildEnumerators[i]; + bool isCurrentChild = i == playlistEnumerator.EnumeratorIndex; + foreach (MediaItem currentMediaItem in childEnumerator.Current) + { + // create a playout history record + var childHistory = new PlayoutHistory + { + PlayoutId = _state.PlayoutId, + PlaybackOrder = enumeratorDetails.PlaybackOrder, + Index = childEnumerator.State.Index, + When = playoutItem.StartOffset.UtcDateTime, + Finish = playoutItem.FinishOffset.UtcDateTime, + Key = enumeratorDetails.HistoryKey, + ChildKey = HistoryDetails.KeyForCollectionKey(collectionKey), + IsCurrentChild = isCurrentChild, + Details = HistoryDetails.ForMediaItem(currentMediaItem) + }; + + result.Add(childHistory); + } + } + } + else + { + // create a playout history record + var nextHistory = new PlayoutHistory + { + PlayoutId = _state.PlayoutId, + PlaybackOrder = enumeratorDetails.PlaybackOrder, + Index = enumeratorDetails.Enumerator.State.Index, + When = playoutItem.StartOffset.UtcDateTime, + Finish = playoutItem.FinishOffset.UtcDateTime, + Key = enumeratorDetails.HistoryKey, + Details = HistoryDetails.ForMediaItem(mediaItem) + }; + + result.Add(nextHistory); + } + + return result; + } + + protected static FillerKind GetFillerKind(Option maybeFillerKind) + { + foreach (FillerKind fillerKind in maybeFillerKind) + { + return fillerKind; + } + + // foreach (FillerKind fillerKind in _state.GetFillerKind()) + // { + // return fillerKind; + // } + + return FillerKind.None; + } + + public record SerializedState( + int? GuideGroup, + bool? GuideGroupLocked); + + private class SchedulingEngineState(int guideGroup) : ISchedulingEngineState + { + private int _guideGroup = guideGroup; + private bool _guideGroupLocked; + + // state + public int PlayoutId { get; set; } + public PlayoutBuildMode Mode { get; set; } + public int Seed { get; set; } + public DateTimeOffset Finish { get; set; } + public DateTimeOffset Start { get; set; } + public DateTimeOffset CurrentTime { get; set; } + + // guide group + public int PeekNextGuideGroup() + { + if (_guideGroupLocked) + { + return _guideGroup; + } + + int result = _guideGroup + 1; + if (result > 1000) + { + result = 1; + } + + return result; + } + + public void AdvanceGuideGroup() + { + if (_guideGroupLocked) + { + return; + } + + _guideGroup++; + if (_guideGroup > 1000) + { + _guideGroup = 1; + } + } + + public void LockGuideGroup(bool advance = true) + { + if (advance) + { + AdvanceGuideGroup(); + } + + _guideGroupLocked = true; + } + + public void UnlockGuideGroup() => _guideGroupLocked = false; + + // result + public Option RemoveBefore { get; set; } + public bool ClearItems { get; set; } + public List AddedItems { get; } = []; + public System.Collections.Generic.HashSet HistoryToRemove { get; } = []; + public List AddedHistory { get; } = []; + + public string SerializeContext() + { + // string preRollSequence = null; + // foreach (string sequence in _preRollSequence) + // { + // preRollSequence = sequence; + // } + + var state = new SerializedState( + _guideGroup, + _guideGroupLocked); + + return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings); + } + + public void LoadContext(SerializedState state) + { + foreach (int guideGroup in Optional(state.GuideGroup)) + { + _guideGroup = guideGroup; + } + + foreach (bool guideGroupLocked in Optional(state.GuideGroupLocked)) + { + _guideGroupLocked = guideGroupLocked; + } + } + } + + private record EnumeratorDetails( + IMediaCollectionEnumerator Enumerator, + string HistoryKey, + PlaybackOrder PlaybackOrder); +} diff --git a/ErsatzTV.Core/Scheduling/HistoryDetails.cs b/ErsatzTV.Core/Scheduling/HistoryDetails.cs index 33e7a33e4..62f9211e6 100644 --- a/ErsatzTV.Core/Scheduling/HistoryDetails.cs +++ b/ErsatzTV.Core/Scheduling/HistoryDetails.cs @@ -49,6 +49,17 @@ internal static class HistoryDetails return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings); } + public static string KeyForSchedulingContent(string key, PlaybackOrder playbackOrder) + { + var historyKey = new Dictionary + { + { "Key", key }, + { "Order", playbackOrder.ToString().ToLowerInvariant() } + }; + + return JsonConvert.SerializeObject(historyKey, Formatting.None, JsonSettings); + } + public static string KeyForCollectionKey(CollectionKey collectionKey) { dynamic key = new diff --git a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs index c5f236688..f0990e06e 100644 --- a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs +++ b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs @@ -1,22 +1,33 @@ using System.Diagnostics.CodeAnalysis; -using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling.Engine; namespace ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules; [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")] -public class ContentModule +[SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits")] +public class ContentModule(ISchedulingEngine schedulingEngine) { - private Dictionary _contentEnumerators = []; + public bool add_search(string key, string query, string order) + { + if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder)) + { + return false; + } + + schedulingEngine.AddSearch(key, query, playbackOrder).GetAwaiter().GetResult(); + + return true; + } - public bool add_collection(string key, string name, string order) + public bool add_collection(string key, string collection, string order) { - if (_contentEnumerators.ContainsKey(key)) + if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder)) { return false; } - Console.WriteLine($"Adding collection '{name}' with key '{key}' and order '{order}'"); - _contentEnumerators.Clear(); + schedulingEngine.AddCollection(key, collection, playbackOrder).GetAwaiter().GetResult(); return true; } diff --git a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs new file mode 100644 index 000000000..c8fbbcccb --- /dev/null +++ b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.CodeAnalysis; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Scheduling.Engine; + +namespace ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules; + +[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")] +[SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits")] +[SuppressMessage("ReSharper", "InconsistentNaming")] +public class PlayoutModule(ISchedulingEngine schedulingEngine) +{ + // content instructions + + 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)) + { + maybeFillerKind = fillerKind; + } + + schedulingEngine.AddCount(content, count, maybeFillerKind, custom_title, disable_watermarks); + } + + + // control instructions + + public void wait_until(string when, bool tomorrow = false, bool rewind_on_reset = false) + { + if (TimeOnly.TryParse(when, out TimeOnly waitUntil)) + { + schedulingEngine.WaitUntil(waitUntil, tomorrow, rewind_on_reset); + } + } +} diff --git a/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs index afc9a2244..ed7a6877b 100644 --- a/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs @@ -1,6 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling.Engine; using ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules; using IronPython.Hosting; using IronPython.Runtime; @@ -9,8 +12,8 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.Scheduling.ScriptedScheduling; public class ScriptedPlayoutBuilder( - //IConfigElementRepository configElementRepository, - //IMediaCollectionRepository mediaCollectionRepository, + IConfigElementRepository configElementRepository, + ISchedulingEngine schedulingEngine, ILocalFileSystem localFileSystem, ILogger logger) : IScriptedPlayoutBuilder @@ -22,27 +25,27 @@ public class ScriptedPlayoutBuilder( PlayoutBuildMode mode, CancellationToken cancellationToken) { - await Task.Delay(10, cancellationToken); - var result = PlayoutBuildResult.Empty; - if (!localFileSystem.FileExists(playout.ScheduleFile)) + try { - logger.LogError("Cannot build scripted playout; schedule file {File} does not exist", playout.ScheduleFile); - return result; - } + if (!localFileSystem.FileExists(playout.ScheduleFile)) + { + logger.LogError("Cannot build scripted playout; schedule file {File} does not exist", playout.ScheduleFile); + return result; + } - logger.LogInformation("Building scripted playout..."); + logger.LogInformation("Building scripted playout..."); - //int daysToBuild = await GetDaysToBuild(); - //DateTimeOffset finish = start.AddDays(daysToBuild); + int daysToBuild = await GetDaysToBuild(); + DateTimeOffset finish = start.AddDays(daysToBuild); - //var enumeratorCache = new EnumeratorCache(mediaCollectionRepository, logger); + schedulingEngine.WithPlayoutId(playout.Id); + schedulingEngine.WithMode(mode); + schedulingEngine.WithSeed(playout.Seed); + schedulingEngine.BuildBetween(start, finish); + schedulingEngine.WithReferenceData(referenceData); - // apply all history??? - - try - { var engine = Python.CreateEngine(); var scope = engine.CreateScope(); @@ -55,10 +58,48 @@ public class ScriptedPlayoutBuilder( dynamic ersatztv = engine.Operations.Invoke(moduleType, "ersatztv"); modules["ersatztv"] = ersatztv; - var contentModule = new ContentModule(); + var contentModule = new ContentModule(schedulingEngine); engine.Operations.SetMember(ersatztv, "content", contentModule); + var playoutModule = new PlayoutModule(schedulingEngine); + engine.Operations.SetMember(ersatztv, "playout", playoutModule); + engine.ExecuteFile(playout.ScheduleFile, scope); + + // define_content is required + if (!scope.TryGetVariable("define_content", out PythonFunction defineContentFunc)) + { + logger.LogError("Script must contain a 'define_content' function"); + return result; + } + + // reset_playout is NOT required + scope.TryGetVariable("reset_playout", out PythonFunction resetPlayoutFunc); + + // build_playout is required + if (!scope.TryGetVariable("build_playout", out PythonFunction buildPlayoutFunc)) + { + logger.LogError("Script must contain a 'build_playout' function"); + return result; + } + + schedulingEngine.RestoreOrReset(Optional(playout.Anchor)); + + // define content first + engine.Operations.Invoke(defineContentFunc); + + // reset if applicable + if (mode is PlayoutBuildMode.Reset && resetPlayoutFunc != null) + { + engine.Operations.Invoke(resetPlayoutFunc, new PythonPlayoutContext(schedulingEngine.GetState())); + } + + // build playout + engine.Operations.Invoke(buildPlayoutFunc, new PythonPlayoutContext(schedulingEngine.GetState())); + + playout.Anchor = schedulingEngine.GetAnchor(); + + result = MergeResult(result, schedulingEngine.GetState()); } catch (Exception ex) { @@ -69,8 +110,28 @@ public class ScriptedPlayoutBuilder( return result; } - // private async Task GetDaysToBuild() => - // await configElementRepository - // .GetValue(ConfigElementKey.PlayoutDaysToBuild) - // .IfNoneAsync(2); + private async Task GetDaysToBuild() => + await configElementRepository + .GetValue(ConfigElementKey.PlayoutDaysToBuild) + .IfNoneAsync(2); + + private static PlayoutBuildResult MergeResult(PlayoutBuildResult result, ISchedulingEngineState state) => + result with + { + ClearItems = state.ClearItems, + RemoveBefore = state.RemoveBefore, + AddedItems = state.AddedItems, + //ItemsToRemove = state.ItemsToRemove, + AddedHistory = state.AddedHistory, + HistoryToRemove = state.HistoryToRemove + }; + + [SuppressMessage("ReSharper", "InconsistentNaming")] + [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")] + public class PythonPlayoutContext(ISchedulingEngineState state) + { + public DateTimeOffset current_time => state.CurrentTime; + public DateTimeOffset finish => state.Finish; + public bool is_done() => current_time >= finish; + } } diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs index 1d0245a74..173dbbb4d 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs @@ -4,6 +4,7 @@ using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Scheduling; +//using ErsatzTV.Core.Scheduling.Engine; using ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; using ErsatzTV.Core.Scheduling.YamlScheduling.Models; using ErsatzTV.Core.Search; @@ -15,6 +16,7 @@ using YamlDotNet.Serialization.NamingConventions; namespace ErsatzTV.Core.Scheduling.YamlScheduling; public class SequentialPlayoutBuilder( + //ISchedulingEngine schedulingEngine, ILocalFileSystem localFileSystem, IConfigElementRepository configElementRepository, IMediaCollectionRepository mediaCollectionRepository, @@ -31,6 +33,9 @@ public class SequentialPlayoutBuilder( PlayoutBuildMode mode, CancellationToken cancellationToken) { + //schedulingEngine.WithMode(mode); + //schedulingEngine.WithSeed(playout.Seed); + PlayoutBuildResult result = PlayoutBuildResult.Empty; if (!localFileSystem.FileExists(playout.ScheduleFile)) @@ -114,6 +119,8 @@ public class SequentialPlayoutBuilder( // InstructionIndex = 0 }; + //schedulingEngine.BuildBetween(start, finish); + // logger.LogDebug( // "Default yaml context from {Start} to {Finish}, instruction {Instruction}", // context.CurrentTime, @@ -123,6 +130,9 @@ public class SequentialPlayoutBuilder( // remove old items // importantly, this should not remove their history result = result with { RemoveBefore = start }; + //schedulingEngine.RemoveBefore(start); + + //schedulingEngine.RestoreOrReset(Optional(playout.Anchor)); // load saved state if (mode is not PlayoutBuildMode.Reset) @@ -166,6 +176,22 @@ public class SequentialPlayoutBuilder( var applyHistoryHandler = new YamlPlayoutApplyHistoryHandler(enumeratorCache); foreach (YamlPlayoutContentItem contentItem in playoutDefinition.Content) { + // if (!Enum.TryParse(contentItem.Order, true, out PlaybackOrder playbackOrder)) + // { + // continue; + // } + // + // switch (contentItem) + // { + // case YamlPlayoutContentCollectionItem collectionItem: + // // also applies history + // await schedulingEngine.AddCollection( + // collectionItem.Key, + // collectionItem.Collection, + // playbackOrder); + // break; + // } + await applyHistoryHandler.Handle( filteredHistory.ToImmutableList(), context, diff --git a/ErsatzTV/Pages/Playouts.razor b/ErsatzTV/Pages/Playouts.razor index 7121f23f1..ea05eaf54 100644 --- a/ErsatzTV/Pages/Playouts.razor +++ b/ErsatzTV/Pages/Playouts.razor @@ -25,7 +25,7 @@ - @* *@ + @@ -39,7 +39,7 @@ - @* *@ + diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 8dce6cafa..bf56f9a62 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -37,6 +37,7 @@ using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling.BlockScheduling; +using ErsatzTV.Core.Scheduling.Engine; using ErsatzTV.Core.Scheduling.ScriptedScheduling; using ErsatzTV.Core.Scheduling.YamlScheduling; using ErsatzTV.Core.Search; @@ -698,6 +699,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();