From 03b4419f672a68d730f4626b6cfd67de8c64f974 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:53:14 -0500 Subject: [PATCH] rework scripted schedules (#2367) * start to reorganize scripted playout building * add openapi * add all content fns * add playout instructions * add control instructions * add request models * prevent build loop * rename * update changelog * tweak changelog --- CHANGELOG.md | 11 + .../Api/ScriptedPlayout/AddAllRequestModel.cs | 9 + .../AddCollectionRequestModel.cs | 8 + .../ScriptedPlayout/AddCountRequestModel.cs | 10 + .../AddDurationRequestModel.cs | 15 + .../AddMarathonRequestModel.cs | 12 + .../AddMultiCollectionRequestModel.cs | 8 + .../AddPlaylistRequestModel.cs | 8 + .../ScriptedPlayout/AddShowRequestModel.cs | 8 + .../AddSmartCollectionRequestModel.cs | 8 + .../ScriptedPlayout/ContextResponseModel.cs | 7 + .../GraphicsOffRequestModel.cs | 6 + .../ScriptedPlayout/GraphicsOnRequestModel.cs | 7 + .../ScriptedPlayout/PadToNextRequestModel.cs | 15 + .../ScriptedPlayout/PadUntilRequestModel.cs | 16 + .../ScriptedPlayout/SkipItemsRequestModel.cs | 7 + .../ScriptedPlayout/SkipToItemRequestModel.cs | 8 + .../StartEpgGroupRequestModel.cs | 6 + .../ScriptedPlayout/WaitUntilRequestModel.cs | 8 + .../WatermarkOffRequestModel.cs | 6 + .../WatermarkOnRequestModel.cs | 6 + ErsatzTV.Core/ErsatzTV.Core.csproj | 2 - .../IScriptedPlayoutBuilderService.cs | 11 + .../Engine/ISchedulingEngineState.cs | 2 + .../Scheduling/Engine/SchedulingEngine.cs | 28 ++ .../Modules/ContentModule.cs | 123 ----- .../Modules/PlayoutModule.cs | 298 ------------ .../ScriptedPlayoutBuilder.cs | 204 ++------ .../ScriptedPlayoutBuilderService.cs | 27 ++ .../Api/ScriptedScheduleController.cs | 439 ++++++++++++++++++ ErsatzTV/ErsatzTV.csproj | 1 + ErsatzTV/Startup.cs | 8 +- 32 files changed, 737 insertions(+), 595 deletions(-) create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/AddAllRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/AddCollectionRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/AddCountRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/AddDurationRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/AddMarathonRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/AddMultiCollectionRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/AddPlaylistRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/AddShowRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/AddSmartCollectionRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/ContextResponseModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOffRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOnRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/PadToNextRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/PadUntilRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/SkipItemsRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/SkipToItemRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/StartEpgGroupRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/WaitUntilRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOffRequestModel.cs create mode 100644 ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOnRequestModel.cs create mode 100644 ErsatzTV.Core/Interfaces/Scheduling/IScriptedPlayoutBuilderService.cs delete mode 100644 ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs delete mode 100644 ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs create mode 100644 ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilderService.cs create mode 100644 ErsatzTV/Controllers/Api/ScriptedScheduleController.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4e28392..8c6e1d1ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Fix transcoding content with bt709/pc color metadata +### Changed +- **BREAKING CHANGE**: change how `Scripted Schedule` system works + - No longer uses embedded python (IronPython); instead uses HTTP API + - OpenAPI Description has been added at `/openapi/scripted-schedule.json` + - This allows scripted scheduling from *many* languages + - The scripted schedule file must now be directly executable (though a wrapper can be used to load a venv) + - The scripted schedule file will be passed the following arguments (in order): + - The API host (e.g. `http://localhost:8409`) + - The build id (a UUID string that is required on all API calls) + - The playout build mode (e.g. `reset` or `continue`, normally only used for specific logic when resetting a playout) + ## [25.5.0] - 2025-09-01 ### Added - Add *experimental* graphics engine diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/AddAllRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/AddAllRequestModel.cs new file mode 100644 index 000000000..b53662da9 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/AddAllRequestModel.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record AddAllRequestModel +{ + public string Content { get; set; } + public string FillerKind { get; set; } + public string CustomTitle { get; set; } + public bool DisableWatermarks { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/AddCollectionRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/AddCollectionRequestModel.cs new file mode 100644 index 000000000..381382aec --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/AddCollectionRequestModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record AddCollectionRequestModel +{ + public string Key { get; init; } + public string Collection { get; init; } + public string Order { get; init; } = "shuffle"; +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/AddCountRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/AddCountRequestModel.cs new file mode 100644 index 000000000..6191ca563 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/AddCountRequestModel.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record AddCountRequestModel +{ + public string Content { get; set; } + public int Count { get; set; } + public string FillerKind { get; set; } + public string CustomTitle { get; set; } + public bool DisableWatermarks { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/AddDurationRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/AddDurationRequestModel.cs new file mode 100644 index 000000000..9416dec74 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/AddDurationRequestModel.cs @@ -0,0 +1,15 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record AddDurationRequestModel +{ + public string Content { get; set; } + public string Duration { get; set; } + public string Fallback { get; set; } + public bool Trim { get; set; } + public int DiscardAttempts { get; set; } + public bool StopBeforeEnd { get; set; } = true; + public bool OfflineTail { get; set; } + public string FillerKind { get; set; } + public string CustomTitle { get; set; } + public bool DisableWatermarks { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/AddMarathonRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/AddMarathonRequestModel.cs new file mode 100644 index 000000000..e6dd598ee --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/AddMarathonRequestModel.cs @@ -0,0 +1,12 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record AddMarathonRequestModel +{ + public string Key { get; set; } + public string GroupBy { get; set; } + public string ItemOrder { get; set; } = "shuffle"; + public Dictionary> Guids { get; set; } = []; + public List Searches { get; set; } = []; + public bool PlayAllItems { get; set; } + public bool ShuffleGroups { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/AddMultiCollectionRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/AddMultiCollectionRequestModel.cs new file mode 100644 index 000000000..ffe16d02c --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/AddMultiCollectionRequestModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record AddMultiCollectionRequestModel +{ + public string Key { get; set; } + public string MultiCollection { get; set; } + public string Order { get; set; } = "shuffle"; +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/AddPlaylistRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/AddPlaylistRequestModel.cs new file mode 100644 index 000000000..7f8ee2e6a --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/AddPlaylistRequestModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record AddPlaylistRequestModel +{ + public string Key { get; set; } + public string Playlist { get; set; } + public string PlaylistGroup { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/AddShowRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/AddShowRequestModel.cs new file mode 100644 index 000000000..dbd600e6a --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/AddShowRequestModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record AddShowRequestModel +{ + public string Key { get; set; } + public Dictionary Guids { get; set; } = []; + public string Order { get; set; } = "shuffle"; +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/AddSmartCollectionRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/AddSmartCollectionRequestModel.cs new file mode 100644 index 000000000..59e0d7c99 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/AddSmartCollectionRequestModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record AddSmartCollectionRequestModel +{ + public string Key { get; set; } + public string SmartCollection { get; set; } + public string Order { get; set; } = "shuffle"; +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/ContextResponseModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/ContextResponseModel.cs new file mode 100644 index 000000000..fb9b91804 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/ContextResponseModel.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record ContextResponseModel( + DateTimeOffset CurrentTime, + DateTimeOffset StartTime, + DateTimeOffset FinishTime, + bool IsDone); diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOffRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOffRequestModel.cs new file mode 100644 index 000000000..cc2668b61 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOffRequestModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record GraphicsOffRequestModel +{ + public List Graphics { get; set; } = []; +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOnRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOnRequestModel.cs new file mode 100644 index 000000000..3b34c33b0 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOnRequestModel.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record GraphicsOnRequestModel +{ + public List Graphics { get; set; } + public Dictionary Variables { get; set; } = []; +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/PadToNextRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/PadToNextRequestModel.cs new file mode 100644 index 000000000..63a668534 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/PadToNextRequestModel.cs @@ -0,0 +1,15 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record PadToNextRequestModel +{ + public string Content { get; set; } + public int Minutes { get; set; } + public string Fallback { get; set; } + public bool Trim { get; set; } + public int DiscardAttempts { get; set; } + public bool StopBeforeEnd { get; set; } = true; + public bool OfflineTail { get; set; } = true; + public string FillerKind { get; set; } + public string CustomTitle { get; set; } + public bool DisableWatermarks { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/PadUntilRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/PadUntilRequestModel.cs new file mode 100644 index 000000000..7405d8a10 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/PadUntilRequestModel.cs @@ -0,0 +1,16 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record PadUntilRequestModel +{ + public string Content { get; set; } + public string When { get; set; } + public bool Tomorrow { get; set; } + public string Fallback { get; set; } + public bool Trim { get; set; } + public int DiscardAttempts { get; set; } + public bool StopBeforeEnd { get; set; } = true; + public bool OfflineTail { get; set; } + public string FillerKind { get; set; } + public string CustomTitle { get; set; } + public bool DisableWatermarks { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/SkipItemsRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/SkipItemsRequestModel.cs new file mode 100644 index 000000000..85eee4f9a --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/SkipItemsRequestModel.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record SkipItemsRequestModel +{ + public string Content { get; set; } + public int Count { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/SkipToItemRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/SkipToItemRequestModel.cs new file mode 100644 index 000000000..3f1502099 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/SkipToItemRequestModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record SkipToItemRequestModel +{ + public string Content { get; set; } + public int Season { get; set; } + public int Episode { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/StartEpgGroupRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/StartEpgGroupRequestModel.cs new file mode 100644 index 000000000..e399475a5 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/StartEpgGroupRequestModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record StartEpgGroupRequestModel +{ + public bool Advance { get; set; } = true; +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/WaitUntilRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/WaitUntilRequestModel.cs new file mode 100644 index 000000000..4736ab655 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/WaitUntilRequestModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record WaitUntilRequestModel +{ + public string When { get; set; } + public bool Tomorrow { get; set; } + public bool RewindOnReset { get; set; } +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOffRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOffRequestModel.cs new file mode 100644 index 000000000..446f2e697 --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOffRequestModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record WatermarkOffRequestModel +{ + public List Watermark { get; set; } = []; +} diff --git a/ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOnRequestModel.cs b/ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOnRequestModel.cs new file mode 100644 index 000000000..edd5087aa --- /dev/null +++ b/ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOnRequestModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Api.ScriptedPlayout; + +public record WatermarkOnRequestModel +{ + public List Watermark { get; set; } +} diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj index a292885f5..47b091395 100644 --- a/ErsatzTV.Core/ErsatzTV.Core.csproj +++ b/ErsatzTV.Core/ErsatzTV.Core.csproj @@ -13,8 +13,6 @@ - - diff --git a/ErsatzTV.Core/Interfaces/Scheduling/IScriptedPlayoutBuilderService.cs b/ErsatzTV.Core/Interfaces/Scheduling/IScriptedPlayoutBuilderService.cs new file mode 100644 index 000000000..20f095d79 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Scheduling/IScriptedPlayoutBuilderService.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Scheduling.Engine; + +namespace ErsatzTV.Core.Interfaces.Scheduling; + +public interface IScriptedPlayoutBuilderService +{ + bool MockSession(ISchedulingEngine schedulingEngine, Guid buildId); + Guid StartSession(ISchedulingEngine schedulingEngine); + ISchedulingEngine GetEngine(Guid buildId); + void EndSession(Guid buildId); +} diff --git a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs index b131c5344..10acb35a8 100644 --- a/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs +++ b/ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs @@ -19,4 +19,6 @@ public interface ISchedulingEngineState List AddedItems { get; } System.Collections.Generic.HashSet HistoryToRemove { get; } List AddedHistory { get; } + + bool IsDone { get; } } diff --git a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs index f0c1d80f4..5c24ac7e7 100644 --- a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs +++ b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs @@ -1351,6 +1351,11 @@ public class SchedulingEngine( private readonly Dictionary _graphicsElements = []; private readonly System.Collections.Generic.HashSet _channelWatermarkIds = []; + // track is_done calls when current_time has not advanced + private DateTimeOffset _lastCheckedTime; + private int _noProgressCounter; + private const int MaxCallsNoProgress = 20; + // state public int PlayoutId { get; set; } public PlayoutBuildMode Mode { get; set; } @@ -1419,6 +1424,29 @@ public class SchedulingEngine( public System.Collections.Generic.HashSet HistoryToRemove { get; } = []; public List AddedHistory { get; } = []; + public bool IsDone + { + get + { + if (CurrentTime == _lastCheckedTime) + { + _noProgressCounter++; + if (_noProgressCounter >= MaxCallsNoProgress) + { + throw new InvalidOperationException( + $"Script execution halted after {MaxCallsNoProgress} consecutive calls to is_done() without time advancing."); + } + } + else + { + _lastCheckedTime = CurrentTime; + _noProgressCounter = 0; + } + + return CurrentTime >= Finish; + } + } + public string SerializeContext() { // string preRollSequence = null; diff --git a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs b/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs deleted file mode 100644 index c44252390..000000000 --- a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs +++ /dev/null @@ -1,123 +0,0 @@ -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, CancellationToken cancellationToken) -{ - public void add_search(string key, string query, string order) - { - if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder)) - { - return; - } - - schedulingEngine.AddSearch(key, query, playbackOrder, cancellationToken).GetAwaiter().GetResult(); - } - - public void add_collection(string key, string collection, string order) - { - if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder)) - { - return; - } - - schedulingEngine.AddCollection(key, collection, playbackOrder, cancellationToken).GetAwaiter().GetResult(); - } - - 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, cancellationToken) - .GetAwaiter() - .GetResult(); - } - - public void add_playlist(string key, string playlist, string playlist_group) - { - schedulingEngine.AddPlaylist(key, playlist, playlist_group, cancellationToken).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, cancellationToken) - .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 deleted file mode 100644 index 3121f0f51..000000000 --- a/ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs +++ /dev/null @@ -1,298 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using ErsatzTV.Core.Domain.Filler; -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 PlayoutModule(ISchedulingEngine schedulingEngine, CancellationToken cancellationToken) -{ - public int FailureCount { get; private set; } - - // content instructions - - public void add_all( - string content, - 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; - } - - bool success = schedulingEngine.AddAll(content, maybeFillerKind, custom_title, disable_watermarks); - if (success) - { - FailureCount = 0; - } - else - { - FailureCount++; - } - } - - 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; - } - - bool success = schedulingEngine.AddCount(content, count, maybeFillerKind, custom_title, disable_watermarks); - if (success) - { - FailureCount = 0; - } - else - { - FailureCount++; - } - } - - public void add_duration( - string content, - string duration, - string fallback = null, - bool trim = false, - int discard_attempts = 0, - bool stop_before_end = true, - bool offline_tail = false, - 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; - } - - bool success = schedulingEngine.AddDuration( - content, - duration, - fallback, - trim, - discard_attempts, - stop_before_end, - offline_tail, - maybeFillerKind, - custom_title, - disable_watermarks); - - if (success) - { - FailureCount = 0; - } - else - { - FailureCount++; - } - } - - public void pad_to_next( - string content, - int minutes, - string fallback = null, - bool trim = false, - int discard_attempts = 0, - bool stop_before_end = true, - bool offline_tail = true, - 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; - } - - bool success = schedulingEngine.PadToNext( - content, - minutes, - fallback, - trim, - discard_attempts, - stop_before_end, - offline_tail, - maybeFillerKind, - custom_title, - disable_watermarks); - - if (success) - { - FailureCount = 0; - } - else - { - FailureCount++; - } - } - - public void pad_until( - string content, - string when, - bool tomorrow = false, - string fallback = null, - bool trim = false, - int discard_attempts = 0, - bool stop_before_end = true, - bool offline_tail = false, - 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; - } - - bool success = schedulingEngine.PadUntil( - content, - when, - tomorrow, - fallback, - trim, - discard_attempts, - stop_before_end, - offline_tail, - maybeFillerKind, - custom_title, - disable_watermarks); - - if (success) - { - FailureCount = 0; - } - else - { - FailureCount++; - } - } - - - // control instructions - - public void start_epg_group(bool advance = true) - { - schedulingEngine.LockGuideGroup(advance); - } - - public void stop_epg_group() - { - schedulingEngine.UnlockGuideGroup(); - } - - public void graphics_on(string graphics, PythonDictionary variables = null) - { - var maybeVariables = new Dictionary(); - if (variables != null) - { - maybeVariables = variables.ToDictionary(v => v.Key.ToString(), v => v.Value.ToString()); - } - - schedulingEngine - .GraphicsOn([graphics], maybeVariables, cancellationToken) - .GetAwaiter() - .GetResult(); - } - - public void graphics_on(PythonList graphics, PythonDictionary variables = null) - { - var maybeVariables = new Dictionary(); - if (variables != null) - { - maybeVariables = variables.ToDictionary(v => v.Key.ToString(), v => v.Value.ToString()); - } - - schedulingEngine - .GraphicsOn( - graphics.Select(g => g.ToString()).ToList(), - maybeVariables, - cancellationToken) - .GetAwaiter() - .GetResult(); - } - - public void graphics_off(string graphics = null) - { - if (string.IsNullOrWhiteSpace(graphics)) - { - schedulingEngine.GraphicsOff([], cancellationToken).GetAwaiter().GetResult(); - } - else - { - schedulingEngine.GraphicsOff([graphics], cancellationToken).GetAwaiter().GetResult(); - } - } - - public void graphics_off(PythonList graphics) - { - schedulingEngine - .GraphicsOff(graphics.Select(g => g.ToString()).ToList(), cancellationToken) - .GetAwaiter() - .GetResult(); - } - - public void watermark_on(string watermark) - { - schedulingEngine.WatermarkOn([watermark]).GetAwaiter().GetResult(); - } - - public void watermark_on(PythonList watermark) - { - schedulingEngine - .WatermarkOn(watermark.Select(g => g.ToString()).ToList()) - .GetAwaiter() - .GetResult(); - } - - public void watermark_off(string watermark = null) - { - if (string.IsNullOrWhiteSpace(watermark)) - { - schedulingEngine.WatermarkOff([]).GetAwaiter().GetResult(); - } - else - { - schedulingEngine.WatermarkOff([watermark]).GetAwaiter().GetResult(); - } - } - - public void watermark_off(PythonList watermark) - { - schedulingEngine.WatermarkOff(watermark.Select(g => g.ToString()).ToList()).GetAwaiter().GetResult(); - } - - public void skip_items(string content, int count) - { - schedulingEngine.SkipItems(content, count); - } - - public void skip_to_item(string content, int season, int episode) - { - schedulingEngine.SkipToItem(content, season, episode); - } - - 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 f16180df6..6d62c25c5 100644 --- a/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs @@ -1,20 +1,17 @@ -using System.Diagnostics.CodeAnalysis; +using CliWrap; +using CliWrap.Buffered; 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; -using IronPython.Runtime.Exceptions; using Microsoft.Extensions.Logging; -using Microsoft.Scripting.Hosting; namespace ErsatzTV.Core.Scheduling.ScriptedScheduling; public class ScriptedPlayoutBuilder( IConfigElementRepository configElementRepository, + IScriptedPlayoutBuilderService scriptedPlayoutBuilderService, ISchedulingEngine schedulingEngine, ILocalFileSystem localFileSystem, ILogger logger) @@ -29,15 +26,19 @@ public class ScriptedPlayoutBuilder( { var result = PlayoutBuildResult.Empty; + Guid buildId = scriptedPlayoutBuilderService.StartSession(schedulingEngine); + try { if (!localFileSystem.FileExists(playout.ScheduleFile)) { - logger.LogError("Cannot build scripted playout; schedule file {File} does not exist", 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 with id {BuildId} ...", buildId); int daysToBuild = await GetDaysToBuild(cancellationToken); DateTimeOffset finish = start.AddDays(daysToBuild); @@ -48,85 +49,34 @@ public class ScriptedPlayoutBuilder( schedulingEngine.BuildBetween(start, finish); schedulingEngine.WithReferenceData(referenceData); - var engine = Python.CreateEngine(); - var scope = engine.CreateScope(); + schedulingEngine.RestoreOrReset(Optional(playout.Anchor)); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var token = cts.Token; - TracebackDelegate traceDelegate = null; - traceDelegate = (_, why, _) => - { - if (why == "line") - { - token.ThrowIfCancellationRequested(); - } - return traceDelegate; - }; - engine.SetTrace(traceDelegate); - - string scriptPath = Path.GetDirectoryName(playout.ScheduleFile); - if (!string.IsNullOrWhiteSpace(scriptPath) && localFileSystem.FolderExists(scriptPath)) - { - ICollection paths = engine.GetSearchPaths(); - paths.Add(scriptPath); - engine.SetSearchPaths(paths); - } - - var contentModule = new ContentModule(schedulingEngine, token); - var playoutModule = new PlayoutModule(schedulingEngine, token); - - 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; - } - - if (defineContentFunc.__code__.co_argcount != 2) - { - logger.LogError("Script function 'define_content' must accept 2 parameters: (content, context)"); - return result; - } - - // reset_playout is NOT required - scope.TryGetVariable("reset_playout", out PythonFunction resetPlayoutFunc); - if (resetPlayoutFunc != null && resetPlayoutFunc.__code__.co_argcount != 2) + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + + Command command = Cli.Wrap(playout.ScheduleFile) + .WithArguments( + [ + $"http://localhost:{Settings.UiPort}", + buildId.ToString(), + mode.ToString().ToLowerInvariant() + ]) + .WithValidation(CommandResultValidation.None); + + var commandResult = await command.ExecuteBufferedAsync(linkedCts.Token); + if (!string.IsNullOrWhiteSpace(commandResult.StandardOutput)) { - logger.LogError("Script function 'reset_playout' must accept 2 parameters: (playout, context)"); - return result; + Console.WriteLine(commandResult.StandardOutput); } - // build_playout is required - if (!scope.TryGetVariable("build_playout", out PythonFunction buildPlayoutFunc)) - { - logger.LogError("Script must contain a 'build_playout' function"); - return result; - } - - if (buildPlayoutFunc.__code__.co_argcount != 2) - { - logger.LogError("Script function 'build_playout' must accept 2 parameters: (playout, context)"); - return result; - } - - schedulingEngine.RestoreOrReset(Optional(playout.Anchor)); - - var context = new PythonPlayoutContext(schedulingEngine.GetState(), playoutModule, engine); - - // define content first - engine.Operations.Invoke(defineContentFunc, contentModule, context); - - // reset if applicable - if (mode is PlayoutBuildMode.Reset && resetPlayoutFunc != null) + if (commandResult.ExitCode != 0) { - engine.Operations.Invoke(resetPlayoutFunc, playoutModule, context); + logger.LogWarning( + "Scripted playout process exited with code {Code}: {Error}", + commandResult.ExitCode, + commandResult.StandardError); } - // build playout - engine.Operations.Invoke(buildPlayoutFunc, playoutModule, context); - playout.Anchor = schedulingEngine.GetAnchor(); result = MergeResult(result, schedulingEngine.GetState()); @@ -141,6 +91,10 @@ public class ScriptedPlayoutBuilder( logger.LogWarning(ex, "Unexpected exception building scripted playout"); throw; } + finally + { + scriptedPlayoutBuilderService.EndSession(buildId); + } return result; } @@ -160,96 +114,4 @@ public class ScriptedPlayoutBuilder( AddedHistory = state.AddedHistory, HistoryToRemove = state.HistoryToRemove }; - - [SuppressMessage("ReSharper", "InconsistentNaming")] - [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")] - public class PythonPlayoutContext - { - private readonly ISchedulingEngineState _state; - private readonly PlayoutModule _playoutModule; - private readonly ScriptEngine _scriptEngine; - private readonly dynamic _datetimeModule; - - // track playout build failures - private const int MaxFailures = 10; - - // track is_done calls when current_time has not advanced - private DateTimeOffset _lastCheckedTime; - private int _noProgressCounter; - private const int MaxCallsNoProgress = 20; - - public PythonPlayoutContext( - ISchedulingEngineState state, - PlayoutModule playoutModule, - ScriptEngine scriptEngine) - { - _state = state; - _playoutModule = playoutModule; - _scriptEngine = scriptEngine; - _datetimeModule = _scriptEngine.ImportModule("datetime"); - _lastCheckedTime = _state.CurrentTime; - } - - public object current_time => ToPythonDateTime(_state.CurrentTime); - public object start_time => ToPythonDateTime(_state.Start); - public object finish_time => ToPythonDateTime(_state.Finish); - - public bool is_done() - { - if (_playoutModule.FailureCount >= MaxFailures) - { - throw new InvalidOperationException( - $"Script execution halted after {MaxFailures} consecutive failures to add content." - ); - } - - if (_state.CurrentTime == _lastCheckedTime) - { - _noProgressCounter++; - if (_noProgressCounter >= MaxCallsNoProgress) - { - throw new InvalidOperationException( - $"Script execution halted after {MaxCallsNoProgress} consecutive calls to is_done() without time advancing."); - } - } - else - { - _lastCheckedTime = _state.CurrentTime; - _noProgressCounter = 0; - } - - return _state.CurrentTime >= _state.Finish; - } - - private object ToPythonDateTime(DateTimeOffset dto) - { - dynamic dt_constructor = _datetimeModule.datetime; - dynamic timedelta_constructor = _datetimeModule.timedelta; - dynamic timezone_constructor = _datetimeModule.timezone; - - var offset = dto.Offset; - dynamic py_offset = _scriptEngine.Operations.Invoke( - timedelta_constructor, - 0, - (int)offset.TotalSeconds - ); - - dynamic py_tzinfo = _scriptEngine.Operations.Invoke( - timezone_constructor, - py_offset - ); - - return _scriptEngine.Operations.Invoke( - dt_constructor, - dto.Year, - dto.Month, - dto.Day, - dto.Hour, - dto.Minute, - dto.Second, - dto.Millisecond * 1000, - py_tzinfo - ); - } - } } diff --git a/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilderService.cs b/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilderService.cs new file mode 100644 index 000000000..bd393529b --- /dev/null +++ b/ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilderService.cs @@ -0,0 +1,27 @@ +using System.Collections.Concurrent; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling.Engine; + +namespace ErsatzTV.Core.Scheduling.ScriptedScheduling; + +public class ScriptedPlayoutBuilderService : IScriptedPlayoutBuilderService +{ + private readonly ConcurrentDictionary _sessions = new(); + + public bool MockSession(ISchedulingEngine schedulingEngine, Guid buildId) => + _sessions.TryAdd(buildId, schedulingEngine); + + public Guid StartSession(ISchedulingEngine schedulingEngine) + { + var buildId = Guid.NewGuid(); + _sessions[buildId] = schedulingEngine; + return buildId; + } + + public ISchedulingEngine GetEngine(Guid buildId) => _sessions.GetValueOrDefault(buildId); + + public void EndSession(Guid buildId) + { + _sessions.TryRemove(buildId, out _); + } +} diff --git a/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs b/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs new file mode 100644 index 000000000..62d4f1e4c --- /dev/null +++ b/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs @@ -0,0 +1,439 @@ +using ErsatzTV.Core.Api.ScriptedPlayout; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling.Engine; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +[EndpointGroupName("scripted-schedule")] +[Route("api/scripted/playout/build/{buildId:guid}")] +public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedPlayoutBuilderService) : ControllerBase +{ + [HttpGet("context", Name = "GetContext")] + public ActionResult GetContext([FromRoute] Guid buildId) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + try + { + var state = engine.GetState(); + return Ok(new ContextResponseModel(state.CurrentTime, state.Start, state.Finish, state.IsDone)); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPost("add_collection", Name = "AddCollection")] + public async Task AddCollection( + [FromRoute] + Guid buildId, + [FromBody] + AddCollectionRequestModel request, + CancellationToken cancellationToken) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + if (!Enum.TryParse(request.Order, ignoreCase: true, out PlaybackOrder playbackOrder)) + { + return BadRequest("Invalid playback order."); + } + + await engine.AddCollection(request.Key, request.Collection, playbackOrder, cancellationToken); + return Ok(); + } + + [HttpPost("add_marathon", Name = "AddMarathon")] + public async Task AddMarathon([FromRoute] Guid buildId, [FromBody] AddMarathonRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + if (!Enum.TryParse(request.ItemOrder, ignoreCase: true, out PlaybackOrder itemPlaybackOrder)) + { + return BadRequest("Invalid item playback order."); + } + + await engine.AddMarathon( + request.Key, + request.Guids ?? [], + request.Searches ?? [], + request.GroupBy, + request.ShuffleGroups, + itemPlaybackOrder, + request.PlayAllItems); + return Ok(); + } + + [HttpPost("add_multi_collection", Name = "AddMultiCollection")] + public async Task AddMultiCollection( + [FromRoute] + Guid buildId, + [FromBody] + AddMultiCollectionRequestModel request, + CancellationToken cancellationToken) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + if (!Enum.TryParse(request.Order, ignoreCase: true, out PlaybackOrder playbackOrder)) + { + return BadRequest("Invalid playback order."); + } + + await engine.AddMultiCollection(request.Key, request.MultiCollection, playbackOrder, cancellationToken); + return Ok(); + } + + [HttpPost("add_playlist", Name = "AddPlaylist")] + public async Task AddPlaylist( + [FromRoute] + Guid buildId, + [FromBody] + AddPlaylistRequestModel request, + CancellationToken cancellationToken) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + await engine.AddPlaylist(request.Key, request.Playlist, request.PlaylistGroup, cancellationToken); + return Ok(); + } + + [HttpPost("add_smart_collection", Name = "AddSmartCollection")] + public async Task AddSmartCollection( + [FromRoute] + Guid buildId, + [FromBody] + AddSmartCollectionRequestModel request, + CancellationToken cancellationToken) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + if (!Enum.TryParse(request.Order, ignoreCase: true, out PlaybackOrder playbackOrder)) + { + return BadRequest("Invalid playback order."); + } + + await engine.AddSmartCollection(request.Key, request.SmartCollection, playbackOrder, cancellationToken); + return Ok(); + } + + [HttpPost("add_show", Name = "AddShow")] + public async Task AddShow( + [FromRoute] + Guid buildId, + [FromBody] + AddShowRequestModel request, + CancellationToken cancellationToken) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + if (!Enum.TryParse(request.Order, ignoreCase: true, out PlaybackOrder playbackOrder)) + { + return BadRequest("Invalid playback order."); + } + + await engine.AddShow(request.Key, request.Guids, playbackOrder); + return Ok(); + } + + [HttpPost("add_all", Name = "AddAll")] + public IActionResult AddAll([FromRoute] Guid buildId, [FromBody] AddAllRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + Option maybeFillerKind = Option.None; + if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk)) + { + maybeFillerKind = fk; + } + + engine.AddAll(request.Content, maybeFillerKind, request.CustomTitle, request.DisableWatermarks); + return Ok(); + } + + [HttpPost("add_count", Name = "AddCount")] + public IActionResult AddCount( + [FromRoute] + Guid buildId, + [FromBody] + AddCountRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + Option maybeFillerKind = Option.None; + if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk)) + { + maybeFillerKind = fk; + } + + engine.AddCount( + request.Content, + request.Count, + maybeFillerKind, + request.CustomTitle, + request.DisableWatermarks); + return Ok(); + } + + [HttpPost("add_duration", Name = "AddDuration")] + public IActionResult AddDuration([FromRoute] Guid buildId, [FromBody] AddDurationRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + Option maybeFillerKind = Option.None; + if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk)) + { + maybeFillerKind = fk; + } + + engine.AddDuration( + request.Content, + request.Duration, + request.Fallback, + request.Trim, + request.DiscardAttempts, + request.StopBeforeEnd, + request.OfflineTail, + maybeFillerKind, + request.CustomTitle, + request.DisableWatermarks); + return Ok(); + } + + [HttpPost("pad_to_next", Name = "PadToNext")] + public IActionResult PadToNext([FromRoute] Guid buildId, [FromBody] PadToNextRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + Option maybeFillerKind = Option.None; + if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk)) + { + maybeFillerKind = fk; + } + + engine.PadToNext( + request.Content, + request.Minutes, + request.Fallback, + request.Trim, + request.DiscardAttempts, + request.StopBeforeEnd, + request.OfflineTail, + maybeFillerKind, + request.CustomTitle, + request.DisableWatermarks); + return Ok(); + } + + [HttpPost("pad_until", Name = "PadUntil")] + public IActionResult PadUntil([FromRoute] Guid buildId, [FromBody] PadUntilRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + Option maybeFillerKind = Option.None; + if (Enum.TryParse(request.FillerKind, ignoreCase: true, out FillerKind fk)) + { + maybeFillerKind = fk; + } + + engine.PadUntil( + request.Content, + request.When, + request.Tomorrow, + request.Fallback, + request.Trim, + request.DiscardAttempts, + request.StopBeforeEnd, + request.OfflineTail, + maybeFillerKind, + request.CustomTitle, + request.DisableWatermarks); + return Ok(); + } + + [HttpGet("start_epg_group", Name = "StartEpgGroup")] + public IActionResult StartEpgGroup([FromRoute] Guid buildId, [FromBody] StartEpgGroupRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + engine.LockGuideGroup(request.Advance); + return Ok(); + } + + [HttpGet("stop_epg_group", Name = "StopEpgGroup")] + public IActionResult StopEpgGroup([FromRoute] Guid buildId) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + engine.UnlockGuideGroup(); + return Ok(); + } + + [HttpGet("graphics_on", Name = "GraphicsOn")] + public async Task GraphicsOn( + [FromRoute] + Guid buildId, + [FromBody] + GraphicsOnRequestModel request, + CancellationToken cancellationToken = default) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + await engine.GraphicsOn(request.Graphics, request.Variables, cancellationToken); + return Ok(); + } + + [HttpGet("graphics_off", Name = "GraphicsOff")] + public async Task GraphicsOff( + [FromRoute] + Guid buildId, + [FromBody] + GraphicsOffRequestModel request, + CancellationToken cancellationToken = default) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + await engine.GraphicsOff(request.Graphics, cancellationToken); + return Ok(); + } + + [HttpGet("watermark_on", Name = "WatermarkOn")] + public async Task WatermarkOn([FromRoute] Guid buildId, [FromBody] WatermarkOnRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + await engine.WatermarkOn(request.Watermark); + return Ok(); + } + + [HttpGet("watermark_off", Name = "WatermarkOff")] + public async Task WatermarkOff([FromRoute] Guid buildId, [FromBody] WatermarkOffRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + await engine.WatermarkOff(request.Watermark); + return Ok(); + } + + [HttpGet("skip_items", Name = "SkipItems")] + public IActionResult SkipItems([FromRoute] Guid buildId, [FromBody] SkipItemsRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + engine.SkipItems(request.Content, request.Count); + return Ok(); + } + + [HttpGet("skip_to_item", Name = "SkipToItem")] + public IActionResult SkipToItem([FromRoute] Guid buildId, [FromBody] SkipToItemRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + engine.SkipToItem(request.Content, request.Season, request.Episode); + return Ok(); + } + + [HttpGet("wait_until", Name = "WaitUntil")] + public IActionResult WaitUntil( + [FromRoute] + Guid buildId, + [FromBody] + WaitUntilRequestModel request) + { + ISchedulingEngine engine = scriptedPlayoutBuilderService.GetEngine(buildId); + if (engine == null) + { + return NotFound($"Active build engine not found for build {buildId}."); + } + + if (TimeOnly.TryParse(request.When, out TimeOnly waitUntil)) + { + engine.WaitUntil(waitUntil, request.Tomorrow, request.RewindOnReset); + } + + return Ok(); + } +} diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 48ebee651..9c350e563 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -28,6 +28,7 @@ + all diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index ec3cc33bd..7dbf6a89c 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -144,6 +144,11 @@ public class Startup services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder)); + services.AddOpenApi("scripted-schedule", options => + { + options.ShouldInclude += a => a.GroupName == "scripted-schedule"; + }); + OidcHelper.Init(Configuration); JwtHelper.Init(Configuration); SearchHelper.Init(Configuration); @@ -592,6 +597,7 @@ public class Startup endpoints.MapControllers(); endpoints.MapBlazorHub(); endpoints.MapFallbackToPage("/_Host"); + endpoints.MapOpenApi().CacheOutput(); }); }); @@ -641,7 +647,7 @@ public class Startup services.AddSingleton(); } - + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton();