Browse Source

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
pull/2368/head
Jason Dove 4 months ago committed by GitHub
parent
commit
03b4419f67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      CHANGELOG.md
  2. 9
      ErsatzTV.Core/Api/ScriptedPlayout/AddAllRequestModel.cs
  3. 8
      ErsatzTV.Core/Api/ScriptedPlayout/AddCollectionRequestModel.cs
  4. 10
      ErsatzTV.Core/Api/ScriptedPlayout/AddCountRequestModel.cs
  5. 15
      ErsatzTV.Core/Api/ScriptedPlayout/AddDurationRequestModel.cs
  6. 12
      ErsatzTV.Core/Api/ScriptedPlayout/AddMarathonRequestModel.cs
  7. 8
      ErsatzTV.Core/Api/ScriptedPlayout/AddMultiCollectionRequestModel.cs
  8. 8
      ErsatzTV.Core/Api/ScriptedPlayout/AddPlaylistRequestModel.cs
  9. 8
      ErsatzTV.Core/Api/ScriptedPlayout/AddShowRequestModel.cs
  10. 8
      ErsatzTV.Core/Api/ScriptedPlayout/AddSmartCollectionRequestModel.cs
  11. 7
      ErsatzTV.Core/Api/ScriptedPlayout/ContextResponseModel.cs
  12. 6
      ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOffRequestModel.cs
  13. 7
      ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOnRequestModel.cs
  14. 15
      ErsatzTV.Core/Api/ScriptedPlayout/PadToNextRequestModel.cs
  15. 16
      ErsatzTV.Core/Api/ScriptedPlayout/PadUntilRequestModel.cs
  16. 7
      ErsatzTV.Core/Api/ScriptedPlayout/SkipItemsRequestModel.cs
  17. 8
      ErsatzTV.Core/Api/ScriptedPlayout/SkipToItemRequestModel.cs
  18. 6
      ErsatzTV.Core/Api/ScriptedPlayout/StartEpgGroupRequestModel.cs
  19. 8
      ErsatzTV.Core/Api/ScriptedPlayout/WaitUntilRequestModel.cs
  20. 6
      ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOffRequestModel.cs
  21. 6
      ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOnRequestModel.cs
  22. 2
      ErsatzTV.Core/ErsatzTV.Core.csproj
  23. 11
      ErsatzTV.Core/Interfaces/Scheduling/IScriptedPlayoutBuilderService.cs
  24. 2
      ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs
  25. 28
      ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs
  26. 123
      ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs
  27. 298
      ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs
  28. 204
      ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs
  29. 27
      ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilderService.cs
  30. 439
      ErsatzTV/Controllers/Api/ScriptedScheduleController.cs
  31. 1
      ErsatzTV/ErsatzTV.csproj
  32. 8
      ErsatzTV/Startup.cs

11
CHANGELOG.md

@ -7,6 +7,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

9
ErsatzTV.Core/Api/ScriptedPlayout/AddAllRequestModel.cs

@ -0,0 +1,9 @@ @@ -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; }
}

8
ErsatzTV.Core/Api/ScriptedPlayout/AddCollectionRequestModel.cs

@ -0,0 +1,8 @@ @@ -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";
}

10
ErsatzTV.Core/Api/ScriptedPlayout/AddCountRequestModel.cs

@ -0,0 +1,10 @@ @@ -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; }
}

15
ErsatzTV.Core/Api/ScriptedPlayout/AddDurationRequestModel.cs

@ -0,0 +1,15 @@ @@ -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; }
}

12
ErsatzTV.Core/Api/ScriptedPlayout/AddMarathonRequestModel.cs

@ -0,0 +1,12 @@ @@ -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<string, List<string>> Guids { get; set; } = [];
public List<string> Searches { get; set; } = [];
public bool PlayAllItems { get; set; }
public bool ShuffleGroups { get; set; }
}

8
ErsatzTV.Core/Api/ScriptedPlayout/AddMultiCollectionRequestModel.cs

@ -0,0 +1,8 @@ @@ -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";
}

8
ErsatzTV.Core/Api/ScriptedPlayout/AddPlaylistRequestModel.cs

@ -0,0 +1,8 @@ @@ -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; }
}

8
ErsatzTV.Core/Api/ScriptedPlayout/AddShowRequestModel.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record AddShowRequestModel
{
public string Key { get; set; }
public Dictionary<string, string> Guids { get; set; } = [];
public string Order { get; set; } = "shuffle";
}

8
ErsatzTV.Core/Api/ScriptedPlayout/AddSmartCollectionRequestModel.cs

@ -0,0 +1,8 @@ @@ -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";
}

7
ErsatzTV.Core/Api/ScriptedPlayout/ContextResponseModel.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record ContextResponseModel(
DateTimeOffset CurrentTime,
DateTimeOffset StartTime,
DateTimeOffset FinishTime,
bool IsDone);

6
ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOffRequestModel.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record GraphicsOffRequestModel
{
public List<string> Graphics { get; set; } = [];
}

7
ErsatzTV.Core/Api/ScriptedPlayout/GraphicsOnRequestModel.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record GraphicsOnRequestModel
{
public List<string> Graphics { get; set; }
public Dictionary<string, string> Variables { get; set; } = [];
}

15
ErsatzTV.Core/Api/ScriptedPlayout/PadToNextRequestModel.cs

@ -0,0 +1,15 @@ @@ -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; }
}

16
ErsatzTV.Core/Api/ScriptedPlayout/PadUntilRequestModel.cs

@ -0,0 +1,16 @@ @@ -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; }
}

7
ErsatzTV.Core/Api/ScriptedPlayout/SkipItemsRequestModel.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record SkipItemsRequestModel
{
public string Content { get; set; }
public int Count { get; set; }
}

8
ErsatzTV.Core/Api/ScriptedPlayout/SkipToItemRequestModel.cs

@ -0,0 +1,8 @@ @@ -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; }
}

6
ErsatzTV.Core/Api/ScriptedPlayout/StartEpgGroupRequestModel.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record StartEpgGroupRequestModel
{
public bool Advance { get; set; } = true;
}

8
ErsatzTV.Core/Api/ScriptedPlayout/WaitUntilRequestModel.cs

@ -0,0 +1,8 @@ @@ -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; }
}

6
ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOffRequestModel.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record WatermarkOffRequestModel
{
public List<string> Watermark { get; set; } = [];
}

6
ErsatzTV.Core/Api/ScriptedPlayout/WatermarkOnRequestModel.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.ScriptedPlayout;
public record WatermarkOnRequestModel
{
public List<string> Watermark { get; set; }
}

2
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -13,8 +13,6 @@ @@ -13,8 +13,6 @@
<PackageReference Include="Destructurama.Attributed" Version="5.1.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="IronPython" Version="3.4.2" />
<PackageReference Include="IronPython.StdLib" Version="3.4.2" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="LanguageExt.Transformers" Version="4.4.8" />
<PackageReference Include="MediatR" Version="[12.5.0]" />

11
ErsatzTV.Core/Interfaces/Scheduling/IScriptedPlayoutBuilderService.cs

@ -0,0 +1,11 @@ @@ -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);
}

2
ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs

@ -19,4 +19,6 @@ public interface ISchedulingEngineState @@ -19,4 +19,6 @@ public interface ISchedulingEngineState
List<PlayoutItem> AddedItems { get; }
System.Collections.Generic.HashSet<int> HistoryToRemove { get; }
List<PlayoutHistory> AddedHistory { get; }
bool IsDone { get; }
}

28
ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs

@ -1351,6 +1351,11 @@ public class SchedulingEngine( @@ -1351,6 +1351,11 @@ public class SchedulingEngine(
private readonly Dictionary<int, string> _graphicsElements = [];
private readonly System.Collections.Generic.HashSet<int> _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( @@ -1419,6 +1424,29 @@ public class SchedulingEngine(
public System.Collections.Generic.HashSet<int> HistoryToRemove { get; } = [];
public List<PlayoutHistory> 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;

123
ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs

@ -1,123 +0,0 @@ @@ -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<string, List<string>>();
if (guids != null)
{
foreach (KeyValuePair<object, object> 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<string>();
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();
}
}

298
ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs

@ -1,298 +0,0 @@ @@ -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<FillerKind> maybeFillerKind = Option<FillerKind>.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<FillerKind> maybeFillerKind = Option<FillerKind>.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<FillerKind> maybeFillerKind = Option<FillerKind>.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<FillerKind> maybeFillerKind = Option<FillerKind>.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<FillerKind> maybeFillerKind = Option<FillerKind>.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<string, string>();
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<string, string>();
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);
}
}
}

204
ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs

@ -1,20 +1,17 @@ @@ -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<ScriptedPlayoutBuilder> logger)
@ -29,15 +26,19 @@ public class ScriptedPlayoutBuilder( @@ -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( @@ -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<string> 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( @@ -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( @@ -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
);
}
}
}

27
ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilderService.cs

@ -0,0 +1,27 @@ @@ -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<Guid, ISchedulingEngine> _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 _);
}
}

439
ErsatzTV/Controllers/Api/ScriptedScheduleController.cs

@ -0,0 +1,439 @@ @@ -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<ContextResponseModel> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<FillerKind> maybeFillerKind = Option<FillerKind>.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<FillerKind> maybeFillerKind = Option<FillerKind>.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<FillerKind> maybeFillerKind = Option<FillerKind>.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<FillerKind> maybeFillerKind = Option<FillerKind>.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<FillerKind> maybeFillerKind = Option<FillerKind>.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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
}

1
ErsatzTV/ErsatzTV.csproj

@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<PrivateAssets>all</PrivateAssets>

8
ErsatzTV/Startup.cs

@ -144,6 +144,11 @@ public class Startup @@ -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 @@ -592,6 +597,7 @@ public class Startup
endpoints.MapControllers();
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
endpoints.MapOpenApi().CacheOutput();
});
});
@ -641,7 +647,7 @@ public class Startup @@ -641,7 +647,7 @@ public class Startup
services.AddSingleton<ISearchIndex, LuceneSearchIndex>();
}
services.AddSingleton<IScriptedPlayoutBuilderService, ScriptedPlayoutBuilderService>();
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
services.AddSingleton<ITempFilePool, TempFilePool>();
services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>();

Loading…
Cancel
Save