Browse Source

add new scheduling engine, basic scripted schedule system (#2337)

* start to add content to scheduling engine

* add first content instruction

* add search content

* allow scripted schedule creation

* don't use scheduling engine in sequential playout builder, yet
pull/2339/head
Jason Dove 11 months ago committed by GitHub
parent
commit
bbddd50f00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 42
      ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs
  3. 21
      ErsatzTV.Core/Scheduling/Engine/ISchedulingEngineState.cs
  4. 655
      ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs
  5. 11
      ErsatzTV.Core/Scheduling/HistoryDetails.cs
  6. 25
      ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/ContentModule.cs
  7. 35
      ErsatzTV.Core/Scheduling/ScriptedScheduling/Modules/PlayoutModule.cs
  8. 103
      ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs
  9. 26
      ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs
  10. 4
      ErsatzTV/Pages/Playouts.razor
  11. 2
      ErsatzTV/Startup.cs

4
CHANGELOG.md

@ -61,6 +61,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `Split Time Evenly` - default (existing) behavior; block time is split among all items that are visible in the EPG - `Split Time Evenly` - default (existing) behavior; block time is split among all items that are visible in the EPG
- `Use Actual Times` - actual times are used for all items that are visible in the EPG - `Use Actual Times` - actual times are used for all items that are visible in the EPG
- This will introduce EPG gaps when filler is used, or when items are hidden from the EPG - This will introduce EPG gaps when filler is used, or when items are hidden from the EPG
- Add *experimental* `Scripted Schedule` playout system
- This system uses python scripts to support the highest degree of customization
- The goal is to expose methods equivalent to all sequential schedule (YAML) instructions
### Fix ### Fix
- Fix database operations that were slowing down playout builds - Fix database operations that were slowing down playout builds
@ -89,6 +92,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `Classic Schedules` - `Classic Schedules`
- `Block Schedules` - `Block Schedules`
- `Sequential Schedules` (formerly `YAML Schedules` or `YAML Playouts`) - `Sequential Schedules` (formerly `YAML Schedules` or `YAML Playouts`)
- `Scripted Schedules`
- `JSON (dizqueTV) Schedules` (formerly `External JSON Playouts`) - `JSON (dizqueTV) Schedules` (formerly `External JSON Playouts`)
- Allow multiple watermarks in playback troubleshooting - Allow multiple watermarks in playback troubleshooting
- Classic schedules: allow selecting multiple watermarks on schedule items - Classic schedules: allow selecting multiple watermarks on schedule items

42
ErsatzTV.Core/Scheduling/Engine/ISchedulingEngine.cs

@ -0,0 +1,42 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Scheduling.Engine;
public interface ISchedulingEngine
{
ISchedulingEngine WithPlayoutId(int playoutId);
ISchedulingEngine WithMode(PlayoutBuildMode mode);
ISchedulingEngine WithSeed(int seed);
ISchedulingEngine WithReferenceData(PlayoutReferenceData referenceData);
ISchedulingEngine BuildBetween(DateTimeOffset start, DateTimeOffset finish);
ISchedulingEngine RemoveBefore(DateTimeOffset removeBefore);
ISchedulingEngine RestoreOrReset(Option<PlayoutAnchor> maybeAnchor);
// content definitions
Task<ISchedulingEngine> AddCollection(string key, string collectionName, PlaybackOrder playbackOrder);
Task<ISchedulingEngine> AddSearch(string key, string query, PlaybackOrder playbackOrder);
// content instructions
ISchedulingEngine AddCount(
string content,
int count,
Option<FillerKind> fillerKind,
string customTitle,
bool disableWatermarks);
// control instructions
ISchedulingEngine WaitUntil(TimeOnly waitUntil, bool tomorrow, bool rewindOnReset);
PlayoutAnchor GetAnchor();
ISchedulingEngineState GetState();
}

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

@ -0,0 +1,21 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Scheduling.Engine;
public interface ISchedulingEngineState
{
// state
int PlayoutId { get; }
PlayoutBuildMode Mode { get; }
int Seed { get; }
DateTimeOffset CurrentTime { get; }
DateTimeOffset Finish { get; }
// result
Option<DateTimeOffset> RemoveBefore { get; }
bool ClearItems { get; }
List<PlayoutItem> AddedItems { get; }
System.Collections.Generic.HashSet<int> HistoryToRemove { get; }
List<PlayoutHistory> AddedHistory { get; }
}

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

@ -0,0 +1,655 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace ErsatzTV.Core.Scheduling.Engine;
public class SchedulingEngine(IMediaCollectionRepository mediaCollectionRepository, ILogger<SchedulingEngine> logger)
: ISchedulingEngine
{
private static readonly JsonSerializerSettings JsonSettings = new()
{
NullValueHandling = NullValueHandling.Ignore
};
private readonly Dictionary<string, EnumeratorDetails> _enumerators = new();
private readonly SchedulingEngineState _state = new(0);
private PlayoutReferenceData _referenceData;
public ISchedulingEngine WithPlayoutId(int playoutId)
{
_state.PlayoutId = playoutId;
return this;
}
public ISchedulingEngine WithMode(PlayoutBuildMode mode)
{
_state.Mode = mode;
return this;
}
public ISchedulingEngine WithSeed(int seed)
{
_state.Seed = seed;
return this;
}
public ISchedulingEngine WithReferenceData(PlayoutReferenceData referenceData)
{
_referenceData = referenceData;
return this;
}
public ISchedulingEngine BuildBetween(DateTimeOffset start, DateTimeOffset finish)
{
_state.Start = start;
_state.Finish = finish;
_state.CurrentTime = start;
return this;
}
public ISchedulingEngine RemoveBefore(DateTimeOffset removeBefore)
{
_state.RemoveBefore = removeBefore;
return this;
}
public ISchedulingEngine RestoreOrReset(Option<PlayoutAnchor> maybeAnchor)
{
if (_state.Mode is PlayoutBuildMode.Reset)
{
// erase items, not history
_state.ClearItems = true;
// remove any future or "currently active" history items
// this prevents "walking" the playout forward by repeatedly resetting
var toRemove = new List<PlayoutHistory>();
toRemove.AddRange(
_referenceData.PlayoutHistory.Filter(h =>
h.When > _state.Start.UtcDateTime ||
h.When <= _state.Start.UtcDateTime && h.Finish >= _state.Start.UtcDateTime));
foreach (PlayoutHistory history in toRemove)
{
_state.HistoryToRemove.Add(history.Id);
}
}
else
{
foreach (PlayoutAnchor anchor in maybeAnchor)
{
_state.CurrentTime = new DateTimeOffset(anchor.NextStart.ToLocalTime(), _state.CurrentTime.Offset);
if (string.IsNullOrWhiteSpace(anchor.Context))
{
break;
}
SerializedState state = JsonConvert.DeserializeObject<SerializedState>(anchor.Context);
_state.LoadContext(state);
}
}
return this;
}
public async Task<ISchedulingEngine> AddCollection(string key, string collectionName, PlaybackOrder playbackOrder)
{
if (!_enumerators.ContainsKey(key))
{
int index = _enumerators.Count;
List<MediaItem> items = await mediaCollectionRepository.GetCollectionItemsByName(collectionName);
if (items.Count == 0)
{
logger.LogWarning("Skipping invalid or empty collection {Name}", collectionName);
return this;
}
var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 };
foreach (var enumerator in EnumeratorForContent(items, state, playbackOrder))
{
string historyKey = HistoryDetails.KeyForSchedulingContent(key, playbackOrder);
var details = new EnumeratorDetails(enumerator, historyKey, playbackOrder);
if (_enumerators.TryAdd(key, details))
{
logger.LogDebug(
"Added collection {Name} with key {Key} and order {Order}",
collectionName,
key,
playbackOrder);
ApplyHistory(historyKey, items, enumerator, playbackOrder);
}
}
}
return this;
}
public async Task<ISchedulingEngine> AddSearch(string key, string query, PlaybackOrder playbackOrder)
{
if (!_enumerators.ContainsKey(key))
{
int index = _enumerators.Count;
List<MediaItem> items = await mediaCollectionRepository.GetSmartCollectionItems(query, string.Empty);
if (items.Count == 0)
{
logger.LogWarning("Skipping invalid or empty search query {Query}", query);
return this;
}
var state = new CollectionEnumeratorState { Seed = _state.Seed + index, Index = 0 };
foreach (var enumerator in EnumeratorForContent(items, state, playbackOrder))
{
string historyKey = HistoryDetails.KeyForSchedulingContent(key, playbackOrder);
var details = new EnumeratorDetails(enumerator, historyKey, playbackOrder);
if (_enumerators.TryAdd(key, details))
{
logger.LogDebug(
"Added search query {Query} with key {Key} and order {Order}",
query,
key,
playbackOrder);
ApplyHistory(historyKey, items, enumerator, playbackOrder);
}
}
}
return this;
}
public ISchedulingEngine AddCount(
string content,
int count,
Option<FillerKind> fillerKind,
string customTitle,
bool disableWatermarks)
{
if (!_enumerators.TryGetValue(content, out EnumeratorDetails enumeratorDetails))
{
logger.LogWarning("Skipping invalid content {Key}", content);
return this;
}
for (var i = 0; i < count; i++)
{
// foreach (string preRollSequence in context.GetPreRollSequence())
// {
// context.PushFillerKind(FillerKind.PreRoll);
// await executeSequence(preRollSequence);
// context.PopFillerKind();
// }
foreach (MediaItem mediaItem in enumeratorDetails.Enumerator.Current)
{
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
// create a playout item
var playoutItem = new PlayoutItem
{
PlayoutId = _state.PlayoutId,
MediaItemId = mediaItem.Id,
Start = _state.CurrentTime.UtcDateTime,
Finish = _state.CurrentTime.UtcDateTime + itemDuration,
InPoint = TimeSpan.Zero,
OutPoint = itemDuration,
FillerKind = GetFillerKind(fillerKind),
CustomTitle = string.IsNullOrWhiteSpace(customTitle) ? null : customTitle,
DisableWatermarks = disableWatermarks,
GuideGroup = _state.PeekNextGuideGroup(),
PlayoutItemWatermarks = [],
PlayoutItemGraphicsElements = []
};
// foreach (int watermarkId in context.GetChannelWatermarkIds())
// {
// playoutItem.PlayoutItemWatermarks.Add(
// new PlayoutItemWatermark
// {
// PlayoutItem = playoutItem,
// WatermarkId = watermarkId
// });
// }
//
// foreach ((int graphicsElementId, string variablesJson) in context.GetGraphicsElements())
// {
// playoutItem.PlayoutItemGraphicsElements.Add(
// new PlayoutItemGraphicsElement
// {
// PlayoutItem = playoutItem,
// GraphicsElementId = graphicsElementId,
// Variables = variablesJson
// });
// }
//await AddItemAndMidRoll(context, playoutItem, mediaItem, executeSequence);
_state.AddedItems.Add(playoutItem);
_state.CurrentTime += playoutItem.OutPoint - playoutItem.InPoint;
_state.AdvanceGuideGroup();
// create history record
List<PlayoutHistory> maybeHistory = GetHistoryForItem(enumeratorDetails, playoutItem, mediaItem);
foreach (PlayoutHistory history in maybeHistory)
{
_state.AddedHistory.Add(history);
}
enumeratorDetails.Enumerator.MoveNext();
}
// foreach (string postRollSequence in context.GetPostRollSequence())
// {
// context.PushFillerKind(FillerKind.PostRoll);
// await executeSequence(postRollSequence);
// context.PopFillerKind();
// }
}
return this;
}
public ISchedulingEngine WaitUntil(TimeOnly waitUntil, bool tomorrow, bool rewindOnReset)
{
var currentTime = _state.CurrentTime;
var dayOnly = DateOnly.FromDateTime(currentTime.LocalDateTime);
var timeOnly = TimeOnly.FromDateTime(currentTime.LocalDateTime);
if (timeOnly > waitUntil)
{
if (tomorrow)
{
// this is wrong when offset changes
dayOnly = dayOnly.AddDays(1);
currentTime = new DateTimeOffset(dayOnly, waitUntil, currentTime.Offset);
}
else if (rewindOnReset && _state.Mode == PlayoutBuildMode.Reset)
{
// maybe wrong when offset changes?
currentTime = new DateTimeOffset(dayOnly, waitUntil, currentTime.Offset);
}
}
else
{
// this is wrong when offset changes
currentTime = new DateTimeOffset(dayOnly, waitUntil, currentTime.Offset);
}
_state.CurrentTime = currentTime;
return this;
}
public PlayoutAnchor GetAnchor()
{
DateTime maxTime = _state.CurrentTime.UtcDateTime;
if (_state.AddedItems.Count > 0)
{
maxTime = _state.AddedItems.Max(i => i.Finish);
}
return new PlayoutAnchor
{
NextStart = maxTime,
Context = _state.SerializeContext()
};
}
public ISchedulingEngineState GetState()
{
return _state;
}
private static Option<IMediaCollectionEnumerator> EnumeratorForContent(
List<MediaItem> items,
CollectionEnumeratorState state,
PlaybackOrder playbackOrder,
bool multiPart = false)
{
switch (playbackOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
case PlaybackOrder.Shuffle:
bool keepMultiPartEpisodesTogether = multiPart;
List<GroupedMediaItem> groupedMediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
}
return Option<IMediaCollectionEnumerator>.None;
}
private void ApplyHistory(
string historyKey,
List<MediaItem> collectionItems,
IMediaCollectionEnumerator enumerator,
PlaybackOrder playbackOrder)
{
DateTime historyTime = _state.CurrentTime.UtcDateTime;
var filteredHistory = _referenceData.PlayoutHistory.ToList();
filteredHistory.RemoveAll(h => _state.HistoryToRemove.Contains(h.Id));
Option<DateTime> maxWhen = filteredHistory
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.Map(h => h.When)
.OrderByDescending(h => h)
.HeadOrNone()
.IfNone(DateTime.MinValue);
var maybeHistory = filteredHistory
.Filter(h => h.Key == historyKey)
.Filter(h => h.When == maxWhen)
.ToList();
if (enumerator is PlaylistEnumerator playlistEnumerator)
{
Option<PlayoutHistory> maybePrimaryHistory = maybeHistory
.Filter(h => string.IsNullOrWhiteSpace(h.ChildKey))
.HeadOrNone();
foreach (PlayoutHistory primaryHistory in maybePrimaryHistory)
{
var hasSetEnumeratorIndex = false;
var childEnumeratorKeys = playlistEnumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList();
foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in
playlistEnumerator.ChildEnumerators)
{
PlaybackOrder itemPlaybackOrder = childEnumerator switch
{
ChronologicalMediaCollectionEnumerator => PlaybackOrder.Chronological,
RandomizedMediaCollectionEnumerator => PlaybackOrder.Random,
ShuffledMediaCollectionEnumerator => PlaybackOrder.Shuffle,
_ => PlaybackOrder.None
};
Option<PlayoutHistory> maybeApplicableHistory = maybeHistory
.Filter(h => h.ChildKey == HistoryDetails.KeyForCollectionKey(collectionKey))
.HeadOrNone();
if (collectionItems.Count == 0)
{
continue;
}
foreach (PlayoutHistory h in maybeApplicableHistory)
{
// logger.LogDebug(
// "History is applicable: {When}: {ChildKey} / {History} / {IsCurrentChild}",
// h.When,
// h.ChildKey,
// h.Details,
// h.IsCurrentChild);
enumerator.ResetState(
new CollectionEnumeratorState
{
Seed = enumerator.State.Seed,
Index = h.Index + (h.IsCurrentChild ? 1 : 0)
});
if (itemPlaybackOrder is PlaybackOrder.Chronological)
{
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
childEnumerator,
itemPlaybackOrder,
true);
}
if (h.IsCurrentChild)
{
// try to find enumerator based on collection key
playlistEnumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey));
hasSetEnumeratorIndex = true;
}
}
}
if (!hasSetEnumeratorIndex)
{
// falling back to enumerator based on index
playlistEnumerator.SetEnumeratorIndex(primaryHistory.Index);
}
// only move next at the end, because that may also move
// the enumerator index
playlistEnumerator.MoveNext();
}
}
else
{
if (collectionItems.Count == 0)
{
return;
}
// seek to the appropriate place in the collection enumerator
foreach (PlayoutHistory h in maybeHistory)
{
// logger.LogDebug("History is applicable: {When}: {History}", h.When, h.Details);
enumerator.ResetState(
new CollectionEnumeratorState { Seed = enumerator.State.Seed, Index = h.Index + 1 });
if (playbackOrder is PlaybackOrder.Chronological)
{
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
enumerator,
playbackOrder);
}
}
}
}
private static TimeSpan DurationForMediaItem(MediaItem mediaItem)
{
if (mediaItem is Image image)
{
return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds);
}
MediaVersion version = mediaItem.GetHeadVersion();
return version.Duration;
}
private List<PlayoutHistory> GetHistoryForItem(
EnumeratorDetails enumeratorDetails,
PlayoutItem playoutItem,
MediaItem mediaItem)
{
var result = new List<PlayoutHistory>();
if (enumeratorDetails.Enumerator is PlaylistEnumerator playlistEnumerator)
{
// create a playout history record
var nextHistory = new PlayoutHistory
{
PlayoutId = _state.PlayoutId,
PlaybackOrder = enumeratorDetails.PlaybackOrder,
Index = playlistEnumerator.EnumeratorIndex,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = enumeratorDetails.HistoryKey,
Details = HistoryDetails.ForMediaItem(mediaItem)
};
result.Add(nextHistory);
for (var i = 0; i < playlistEnumerator.ChildEnumerators.Count; i++)
{
(IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) =
playlistEnumerator.ChildEnumerators[i];
bool isCurrentChild = i == playlistEnumerator.EnumeratorIndex;
foreach (MediaItem currentMediaItem in childEnumerator.Current)
{
// create a playout history record
var childHistory = new PlayoutHistory
{
PlayoutId = _state.PlayoutId,
PlaybackOrder = enumeratorDetails.PlaybackOrder,
Index = childEnumerator.State.Index,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = enumeratorDetails.HistoryKey,
ChildKey = HistoryDetails.KeyForCollectionKey(collectionKey),
IsCurrentChild = isCurrentChild,
Details = HistoryDetails.ForMediaItem(currentMediaItem)
};
result.Add(childHistory);
}
}
}
else
{
// create a playout history record
var nextHistory = new PlayoutHistory
{
PlayoutId = _state.PlayoutId,
PlaybackOrder = enumeratorDetails.PlaybackOrder,
Index = enumeratorDetails.Enumerator.State.Index,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = enumeratorDetails.HistoryKey,
Details = HistoryDetails.ForMediaItem(mediaItem)
};
result.Add(nextHistory);
}
return result;
}
protected static FillerKind GetFillerKind(Option<FillerKind> maybeFillerKind)
{
foreach (FillerKind fillerKind in maybeFillerKind)
{
return fillerKind;
}
// foreach (FillerKind fillerKind in _state.GetFillerKind())
// {
// return fillerKind;
// }
return FillerKind.None;
}
public record SerializedState(
int? GuideGroup,
bool? GuideGroupLocked);
private class SchedulingEngineState(int guideGroup) : ISchedulingEngineState
{
private int _guideGroup = guideGroup;
private bool _guideGroupLocked;
// state
public int PlayoutId { get; set; }
public PlayoutBuildMode Mode { get; set; }
public int Seed { get; set; }
public DateTimeOffset Finish { get; set; }
public DateTimeOffset Start { get; set; }
public DateTimeOffset CurrentTime { get; set; }
// guide group
public int PeekNextGuideGroup()
{
if (_guideGroupLocked)
{
return _guideGroup;
}
int result = _guideGroup + 1;
if (result > 1000)
{
result = 1;
}
return result;
}
public void AdvanceGuideGroup()
{
if (_guideGroupLocked)
{
return;
}
_guideGroup++;
if (_guideGroup > 1000)
{
_guideGroup = 1;
}
}
public void LockGuideGroup(bool advance = true)
{
if (advance)
{
AdvanceGuideGroup();
}
_guideGroupLocked = true;
}
public void UnlockGuideGroup() => _guideGroupLocked = false;
// result
public Option<DateTimeOffset> RemoveBefore { get; set; }
public bool ClearItems { get; set; }
public List<PlayoutItem> AddedItems { get; } = [];
public System.Collections.Generic.HashSet<int> HistoryToRemove { get; } = [];
public List<PlayoutHistory> AddedHistory { get; } = [];
public string SerializeContext()
{
// string preRollSequence = null;
// foreach (string sequence in _preRollSequence)
// {
// preRollSequence = sequence;
// }
var state = new SerializedState(
_guideGroup,
_guideGroupLocked);
return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings);
}
public void LoadContext(SerializedState state)
{
foreach (int guideGroup in Optional(state.GuideGroup))
{
_guideGroup = guideGroup;
}
foreach (bool guideGroupLocked in Optional(state.GuideGroupLocked))
{
_guideGroupLocked = guideGroupLocked;
}
}
}
private record EnumeratorDetails(
IMediaCollectionEnumerator Enumerator,
string HistoryKey,
PlaybackOrder PlaybackOrder);
}

11
ErsatzTV.Core/Scheduling/HistoryDetails.cs

@ -49,6 +49,17 @@ internal static class HistoryDetails
return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings); return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings);
} }
public static string KeyForSchedulingContent(string key, PlaybackOrder playbackOrder)
{
var historyKey = new Dictionary<string, object>
{
{ "Key", key },
{ "Order", playbackOrder.ToString().ToLowerInvariant() }
};
return JsonConvert.SerializeObject(historyKey, Formatting.None, JsonSettings);
}
public static string KeyForCollectionKey(CollectionKey collectionKey) public static string KeyForCollectionKey(CollectionKey collectionKey)
{ {
dynamic key = new dynamic key = new

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

@ -1,22 +1,33 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling.Engine;
namespace ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules; namespace ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules;
[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")] [SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")]
public class ContentModule [SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits")]
public class ContentModule(ISchedulingEngine schedulingEngine)
{ {
private Dictionary<string, IMediaCollectionEnumerator> _contentEnumerators = []; public bool add_search(string key, string query, string order)
{
if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder))
{
return false;
}
schedulingEngine.AddSearch(key, query, playbackOrder).GetAwaiter().GetResult();
return true;
}
public bool add_collection(string key, string name, string order) public bool add_collection(string key, string collection, string order)
{ {
if (_contentEnumerators.ContainsKey(key)) if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder))
{ {
return false; return false;
} }
Console.WriteLine($"Adding collection '{name}' with key '{key}' and order '{order}'"); schedulingEngine.AddCollection(key, collection, playbackOrder).GetAwaiter().GetResult();
_contentEnumerators.Clear();
return true; return true;
} }

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

@ -0,0 +1,35 @@
using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Scheduling.Engine;
namespace ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules;
[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")]
[SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public class PlayoutModule(ISchedulingEngine schedulingEngine)
{
// content instructions
public void add_count(string content, int count, string filler_kind = null, string custom_title = null, bool disable_watermarks = false)
{
Option<FillerKind> maybeFillerKind = Option<FillerKind>.None;
if (Enum.TryParse(filler_kind, ignoreCase: true, out FillerKind fillerKind))
{
maybeFillerKind = fillerKind;
}
schedulingEngine.AddCount(content, count, maybeFillerKind, custom_title, disable_watermarks);
}
// control instructions
public void wait_until(string when, bool tomorrow = false, bool rewind_on_reset = false)
{
if (TimeOnly.TryParse(when, out TimeOnly waitUntil))
{
schedulingEngine.WaitUntil(waitUntil, tomorrow, rewind_on_reset);
}
}
}

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

@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules; using ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules;
using IronPython.Hosting; using IronPython.Hosting;
using IronPython.Runtime; using IronPython.Runtime;
@ -9,8 +12,8 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Scheduling.ScriptedScheduling; namespace ErsatzTV.Core.Scheduling.ScriptedScheduling;
public class ScriptedPlayoutBuilder( public class ScriptedPlayoutBuilder(
//IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
//IMediaCollectionRepository mediaCollectionRepository, ISchedulingEngine schedulingEngine,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<ScriptedPlayoutBuilder> logger) ILogger<ScriptedPlayoutBuilder> logger)
: IScriptedPlayoutBuilder : IScriptedPlayoutBuilder
@ -22,27 +25,27 @@ public class ScriptedPlayoutBuilder(
PlayoutBuildMode mode, PlayoutBuildMode mode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await Task.Delay(10, cancellationToken);
var result = PlayoutBuildResult.Empty; var result = PlayoutBuildResult.Empty;
if (!localFileSystem.FileExists(playout.ScheduleFile)) try
{ {
logger.LogError("Cannot build scripted playout; schedule file {File} does not exist", playout.ScheduleFile); if (!localFileSystem.FileExists(playout.ScheduleFile))
return result; {
} logger.LogError("Cannot build scripted playout; schedule file {File} does not exist", playout.ScheduleFile);
return result;
}
logger.LogInformation("Building scripted playout..."); logger.LogInformation("Building scripted playout...");
//int daysToBuild = await GetDaysToBuild(); int daysToBuild = await GetDaysToBuild();
//DateTimeOffset finish = start.AddDays(daysToBuild); DateTimeOffset finish = start.AddDays(daysToBuild);
//var enumeratorCache = new EnumeratorCache(mediaCollectionRepository, logger); schedulingEngine.WithPlayoutId(playout.Id);
schedulingEngine.WithMode(mode);
schedulingEngine.WithSeed(playout.Seed);
schedulingEngine.BuildBetween(start, finish);
schedulingEngine.WithReferenceData(referenceData);
// apply all history???
try
{
var engine = Python.CreateEngine(); var engine = Python.CreateEngine();
var scope = engine.CreateScope(); var scope = engine.CreateScope();
@ -55,10 +58,48 @@ public class ScriptedPlayoutBuilder(
dynamic ersatztv = engine.Operations.Invoke(moduleType, "ersatztv"); dynamic ersatztv = engine.Operations.Invoke(moduleType, "ersatztv");
modules["ersatztv"] = ersatztv; modules["ersatztv"] = ersatztv;
var contentModule = new ContentModule(); var contentModule = new ContentModule(schedulingEngine);
engine.Operations.SetMember(ersatztv, "content", contentModule); engine.Operations.SetMember(ersatztv, "content", contentModule);
var playoutModule = new PlayoutModule(schedulingEngine);
engine.Operations.SetMember(ersatztv, "playout", playoutModule);
engine.ExecuteFile(playout.ScheduleFile, scope); engine.ExecuteFile(playout.ScheduleFile, scope);
// define_content is required
if (!scope.TryGetVariable("define_content", out PythonFunction defineContentFunc))
{
logger.LogError("Script must contain a 'define_content' function");
return result;
}
// reset_playout is NOT required
scope.TryGetVariable("reset_playout", out PythonFunction resetPlayoutFunc);
// build_playout is required
if (!scope.TryGetVariable("build_playout", out PythonFunction buildPlayoutFunc))
{
logger.LogError("Script must contain a 'build_playout' function");
return result;
}
schedulingEngine.RestoreOrReset(Optional(playout.Anchor));
// define content first
engine.Operations.Invoke(defineContentFunc);
// reset if applicable
if (mode is PlayoutBuildMode.Reset && resetPlayoutFunc != null)
{
engine.Operations.Invoke(resetPlayoutFunc, new PythonPlayoutContext(schedulingEngine.GetState()));
}
// build playout
engine.Operations.Invoke(buildPlayoutFunc, new PythonPlayoutContext(schedulingEngine.GetState()));
playout.Anchor = schedulingEngine.GetAnchor();
result = MergeResult(result, schedulingEngine.GetState());
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -69,8 +110,28 @@ public class ScriptedPlayoutBuilder(
return result; return result;
} }
// private async Task<int> GetDaysToBuild() => private async Task<int> GetDaysToBuild() =>
// await configElementRepository await configElementRepository
// .GetValue<int>(ConfigElementKey.PlayoutDaysToBuild) .GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
// .IfNoneAsync(2); .IfNoneAsync(2);
private static PlayoutBuildResult MergeResult(PlayoutBuildResult result, ISchedulingEngineState state) =>
result with
{
ClearItems = state.ClearItems,
RemoveBefore = state.RemoveBefore,
AddedItems = state.AddedItems,
//ItemsToRemove = state.ItemsToRemove,
AddedHistory = state.AddedHistory,
HistoryToRemove = state.HistoryToRemove
};
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")]
public class PythonPlayoutContext(ISchedulingEngineState state)
{
public DateTimeOffset current_time => state.CurrentTime;
public DateTimeOffset finish => state.Finish;
public bool is_done() => current_time >= finish;
}
} }

26
ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs

@ -4,6 +4,7 @@ using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
//using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; using ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
using ErsatzTV.Core.Scheduling.YamlScheduling.Models; using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
using ErsatzTV.Core.Search; using ErsatzTV.Core.Search;
@ -15,6 +16,7 @@ using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Core.Scheduling.YamlScheduling; namespace ErsatzTV.Core.Scheduling.YamlScheduling;
public class SequentialPlayoutBuilder( public class SequentialPlayoutBuilder(
//ISchedulingEngine schedulingEngine,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
@ -31,6 +33,9 @@ public class SequentialPlayoutBuilder(
PlayoutBuildMode mode, PlayoutBuildMode mode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
//schedulingEngine.WithMode(mode);
//schedulingEngine.WithSeed(playout.Seed);
PlayoutBuildResult result = PlayoutBuildResult.Empty; PlayoutBuildResult result = PlayoutBuildResult.Empty;
if (!localFileSystem.FileExists(playout.ScheduleFile)) if (!localFileSystem.FileExists(playout.ScheduleFile))
@ -114,6 +119,8 @@ public class SequentialPlayoutBuilder(
// InstructionIndex = 0 // InstructionIndex = 0
}; };
//schedulingEngine.BuildBetween(start, finish);
// logger.LogDebug( // logger.LogDebug(
// "Default yaml context from {Start} to {Finish}, instruction {Instruction}", // "Default yaml context from {Start} to {Finish}, instruction {Instruction}",
// context.CurrentTime, // context.CurrentTime,
@ -123,6 +130,9 @@ public class SequentialPlayoutBuilder(
// remove old items // remove old items
// importantly, this should not remove their history // importantly, this should not remove their history
result = result with { RemoveBefore = start }; result = result with { RemoveBefore = start };
//schedulingEngine.RemoveBefore(start);
//schedulingEngine.RestoreOrReset(Optional(playout.Anchor));
// load saved state // load saved state
if (mode is not PlayoutBuildMode.Reset) if (mode is not PlayoutBuildMode.Reset)
@ -166,6 +176,22 @@ public class SequentialPlayoutBuilder(
var applyHistoryHandler = new YamlPlayoutApplyHistoryHandler(enumeratorCache); var applyHistoryHandler = new YamlPlayoutApplyHistoryHandler(enumeratorCache);
foreach (YamlPlayoutContentItem contentItem in playoutDefinition.Content) foreach (YamlPlayoutContentItem contentItem in playoutDefinition.Content)
{ {
// if (!Enum.TryParse(contentItem.Order, true, out PlaybackOrder playbackOrder))
// {
// continue;
// }
//
// switch (contentItem)
// {
// case YamlPlayoutContentCollectionItem collectionItem:
// // also applies history
// await schedulingEngine.AddCollection(
// collectionItem.Key,
// collectionItem.Collection,
// playbackOrder);
// break;
// }
await applyHistoryHandler.Handle( await applyHistoryHandler.Handle(
filteredHistory.ToImmutableList(), filteredHistory.ToImmutableList(),
context, context,

4
ErsatzTV/Pages/Playouts.razor

@ -25,7 +25,7 @@
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Classic Schedule" Href="playouts/add"/> <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Classic Schedule" Href="playouts/add"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Block Schedule" Href="@($"playouts/add/{PlayoutKind.Block}")"/> <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Block Schedule" Href="@($"playouts/add/{PlayoutKind.Block}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Sequential Schedule" Href="@($"playouts/add/{PlayoutKind.Sequential}")"/> <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Sequential Schedule" Href="@($"playouts/add/{PlayoutKind.Sequential}")"/>
@* <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Scripted Schedule" Href="@($"playouts/add/{PlayoutKind.Scripted}")"/> *@ <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Scripted Schedule" Href="@($"playouts/add/{PlayoutKind.Scripted}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Warning" Label="JSON (dizqueTV) Schedule" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")"/> <MudMenuItem Icon="@Icons.Material.Filled.Warning" Label="JSON (dizqueTV) Schedule" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")"/>
</ChildContent> </ChildContent>
</MudMenu> </MudMenu>
@ -39,7 +39,7 @@
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Classic Playout" Href="playouts/add"/> <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Classic Playout" Href="playouts/add"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Block Playout" Href="@($"playouts/add/{PlayoutKind.Block}")"/> <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Block Playout" Href="@($"playouts/add/{PlayoutKind.Block}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Sequential Playout" Href="@($"playouts/add/{PlayoutKind.Sequential}")"/> <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Sequential Playout" Href="@($"playouts/add/{PlayoutKind.Sequential}")"/>
@* <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Scripted Playout" Href="@($"playouts/add/{PlayoutKind.Scripted}")"/> *@ <MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Scripted Playout" Href="@($"playouts/add/{PlayoutKind.Scripted}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Warning" Label="Add JSON (dizqueTV) Playout" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")"/> <MudMenuItem Icon="@Icons.Material.Filled.Warning" Label="Add JSON (dizqueTV) Playout" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")"/>
<MudMenuItem Icon="@Icons.Material.Filled.Refresh" Label="Reset All Playouts" OnClick="@ResetAllPlayouts"/> <MudMenuItem Icon="@Icons.Material.Filled.Refresh" Label="Reset All Playouts" OnClick="@ResetAllPlayouts"/>
</MudMenu> </MudMenu>

2
ErsatzTV/Startup.cs

@ -37,6 +37,7 @@ using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex; using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling; using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling; using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling; using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Core.Search; using ErsatzTV.Core.Search;
@ -698,6 +699,7 @@ public class Startup
services.AddScoped<IBlockPlayoutFillerBuilder, BlockPlayoutFillerBuilder>(); services.AddScoped<IBlockPlayoutFillerBuilder, BlockPlayoutFillerBuilder>();
services.AddScoped<ISequentialPlayoutBuilder, SequentialPlayoutBuilder>(); services.AddScoped<ISequentialPlayoutBuilder, SequentialPlayoutBuilder>();
services.AddScoped<IScriptedPlayoutBuilder, ScriptedPlayoutBuilder>(); services.AddScoped<IScriptedPlayoutBuilder, ScriptedPlayoutBuilder>();
services.AddScoped<ISchedulingEngine, SchedulingEngine>();
services.AddScoped<IExternalJsonPlayoutBuilder, ExternalJsonPlayoutBuilder>(); services.AddScoped<IExternalJsonPlayoutBuilder, ExternalJsonPlayoutBuilder>();
services.AddScoped<IPlayoutTimeShifter, PlayoutTimeShifter>(); services.AddScoped<IPlayoutTimeShifter, PlayoutTimeShifter>();
services.AddScoped<IImageCache, ImageCache>(); services.AddScoped<IImageCache, ImageCache>();

Loading…
Cancel
Save