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
This commit is contained in:
@@ -61,6 +61,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `Split Time Evenly` - default (existing) behavior; block time is split among all items that are visible in the EPG
|
||||
- `Use Actual Times` - actual times are used for all items that are visible in the EPG
|
||||
- This will introduce EPG gaps when filler is used, or when items are hidden from the EPG
|
||||
- Add *experimental* `Scripted Schedule` playout system
|
||||
- This system uses python scripts to support the highest degree of customization
|
||||
- The goal is to expose methods equivalent to all sequential schedule (YAML) instructions
|
||||
|
||||
### Fix
|
||||
- Fix database operations that were slowing down playout builds
|
||||
@@ -89,6 +92,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `Classic Schedules`
|
||||
- `Block Schedules`
|
||||
- `Sequential Schedules` (formerly `YAML Schedules` or `YAML Playouts`)
|
||||
- `Scripted Schedules`
|
||||
- `JSON (dizqueTV) Schedules` (formerly `External JSON Playouts`)
|
||||
- Allow multiple watermarks in playback troubleshooting
|
||||
- Classic schedules: allow selecting multiple watermarks on schedule items
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -49,6 +49,17 @@ internal static class HistoryDetails
|
||||
return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings);
|
||||
}
|
||||
|
||||
public static string KeyForSchedulingContent(string key, PlaybackOrder playbackOrder)
|
||||
{
|
||||
var historyKey = new Dictionary<string, object>
|
||||
{
|
||||
{ "Key", key },
|
||||
{ "Order", playbackOrder.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
return JsonConvert.SerializeObject(historyKey, Formatting.None, JsonSettings);
|
||||
}
|
||||
|
||||
public static string KeyForCollectionKey(CollectionKey collectionKey)
|
||||
{
|
||||
dynamic key = new
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling.Engine;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules;
|
||||
|
||||
[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")]
|
||||
public class ContentModule
|
||||
[SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits")]
|
||||
public class ContentModule(ISchedulingEngine schedulingEngine)
|
||||
{
|
||||
private Dictionary<string, IMediaCollectionEnumerator> _contentEnumerators = [];
|
||||
|
||||
public bool add_collection(string key, string name, string order)
|
||||
public bool add_search(string key, string query, string order)
|
||||
{
|
||||
if (_contentEnumerators.ContainsKey(key))
|
||||
if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Adding collection '{name}' with key '{key}' and order '{order}'");
|
||||
_contentEnumerators.Clear();
|
||||
schedulingEngine.AddSearch(key, query, playbackOrder).GetAwaiter().GetResult();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool add_collection(string key, string collection, string order)
|
||||
{
|
||||
if (!Enum.TryParse(order, ignoreCase: true, out PlaybackOrder playbackOrder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
schedulingEngine.AddCollection(key, collection, playbackOrder).GetAwaiter().GetResult();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.Engine;
|
||||
using ErsatzTV.Core.Scheduling.ScriptedScheduling.Modules;
|
||||
using IronPython.Hosting;
|
||||
using IronPython.Runtime;
|
||||
@@ -9,8 +12,8 @@ using Microsoft.Extensions.Logging;
|
||||
namespace ErsatzTV.Core.Scheduling.ScriptedScheduling;
|
||||
|
||||
public class ScriptedPlayoutBuilder(
|
||||
//IConfigElementRepository configElementRepository,
|
||||
//IMediaCollectionRepository mediaCollectionRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ISchedulingEngine schedulingEngine,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<ScriptedPlayoutBuilder> logger)
|
||||
: IScriptedPlayoutBuilder
|
||||
@@ -22,27 +25,27 @@ public class ScriptedPlayoutBuilder(
|
||||
PlayoutBuildMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(10, cancellationToken);
|
||||
|
||||
var result = PlayoutBuildResult.Empty;
|
||||
|
||||
if (!localFileSystem.FileExists(playout.ScheduleFile))
|
||||
{
|
||||
logger.LogError("Cannot build scripted playout; schedule file {File} does not exist", playout.ScheduleFile);
|
||||
return result;
|
||||
}
|
||||
|
||||
logger.LogInformation("Building scripted playout...");
|
||||
|
||||
//int daysToBuild = await GetDaysToBuild();
|
||||
//DateTimeOffset finish = start.AddDays(daysToBuild);
|
||||
|
||||
//var enumeratorCache = new EnumeratorCache(mediaCollectionRepository, logger);
|
||||
|
||||
// apply all history???
|
||||
|
||||
try
|
||||
{
|
||||
if (!localFileSystem.FileExists(playout.ScheduleFile))
|
||||
{
|
||||
logger.LogError("Cannot build scripted playout; schedule file {File} does not exist", playout.ScheduleFile);
|
||||
return result;
|
||||
}
|
||||
|
||||
logger.LogInformation("Building scripted playout...");
|
||||
|
||||
int daysToBuild = await GetDaysToBuild();
|
||||
DateTimeOffset finish = start.AddDays(daysToBuild);
|
||||
|
||||
schedulingEngine.WithPlayoutId(playout.Id);
|
||||
schedulingEngine.WithMode(mode);
|
||||
schedulingEngine.WithSeed(playout.Seed);
|
||||
schedulingEngine.BuildBetween(start, finish);
|
||||
schedulingEngine.WithReferenceData(referenceData);
|
||||
|
||||
var engine = Python.CreateEngine();
|
||||
var scope = engine.CreateScope();
|
||||
|
||||
@@ -55,10 +58,48 @@ public class ScriptedPlayoutBuilder(
|
||||
dynamic ersatztv = engine.Operations.Invoke(moduleType, "ersatztv");
|
||||
modules["ersatztv"] = ersatztv;
|
||||
|
||||
var contentModule = new ContentModule();
|
||||
var contentModule = new ContentModule(schedulingEngine);
|
||||
engine.Operations.SetMember(ersatztv, "content", contentModule);
|
||||
|
||||
var playoutModule = new PlayoutModule(schedulingEngine);
|
||||
engine.Operations.SetMember(ersatztv, "playout", playoutModule);
|
||||
|
||||
engine.ExecuteFile(playout.ScheduleFile, scope);
|
||||
|
||||
// define_content is required
|
||||
if (!scope.TryGetVariable("define_content", out PythonFunction defineContentFunc))
|
||||
{
|
||||
logger.LogError("Script must contain a 'define_content' function");
|
||||
return result;
|
||||
}
|
||||
|
||||
// reset_playout is NOT required
|
||||
scope.TryGetVariable("reset_playout", out PythonFunction resetPlayoutFunc);
|
||||
|
||||
// build_playout is required
|
||||
if (!scope.TryGetVariable("build_playout", out PythonFunction buildPlayoutFunc))
|
||||
{
|
||||
logger.LogError("Script must contain a 'build_playout' function");
|
||||
return result;
|
||||
}
|
||||
|
||||
schedulingEngine.RestoreOrReset(Optional(playout.Anchor));
|
||||
|
||||
// define content first
|
||||
engine.Operations.Invoke(defineContentFunc);
|
||||
|
||||
// reset if applicable
|
||||
if (mode is PlayoutBuildMode.Reset && resetPlayoutFunc != null)
|
||||
{
|
||||
engine.Operations.Invoke(resetPlayoutFunc, new PythonPlayoutContext(schedulingEngine.GetState()));
|
||||
}
|
||||
|
||||
// build playout
|
||||
engine.Operations.Invoke(buildPlayoutFunc, new PythonPlayoutContext(schedulingEngine.GetState()));
|
||||
|
||||
playout.Anchor = schedulingEngine.GetAnchor();
|
||||
|
||||
result = MergeResult(result, schedulingEngine.GetState());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -69,8 +110,28 @@ public class ScriptedPlayoutBuilder(
|
||||
return result;
|
||||
}
|
||||
|
||||
// private async Task<int> GetDaysToBuild() =>
|
||||
// await configElementRepository
|
||||
// .GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
|
||||
// .IfNoneAsync(2);
|
||||
private async Task<int> GetDaysToBuild() =>
|
||||
await configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
private static PlayoutBuildResult MergeResult(PlayoutBuildResult result, ISchedulingEngineState state) =>
|
||||
result with
|
||||
{
|
||||
ClearItems = state.ClearItems,
|
||||
RemoveBefore = state.RemoveBefore,
|
||||
AddedItems = state.AddedItems,
|
||||
//ItemsToRemove = state.ItemsToRemove,
|
||||
AddedHistory = state.AddedHistory,
|
||||
HistoryToRemove = state.HistoryToRemove
|
||||
};
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores")]
|
||||
public class PythonPlayoutContext(ISchedulingEngineState state)
|
||||
{
|
||||
public DateTimeOffset current_time => state.CurrentTime;
|
||||
public DateTimeOffset finish => state.Finish;
|
||||
public bool is_done() => current_time >= finish;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
//using ErsatzTV.Core.Scheduling.Engine;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -15,6 +16,7 @@ using YamlDotNet.Serialization.NamingConventions;
|
||||
namespace ErsatzTV.Core.Scheduling.YamlScheduling;
|
||||
|
||||
public class SequentialPlayoutBuilder(
|
||||
//ISchedulingEngine schedulingEngine,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
@@ -31,6 +33,9 @@ public class SequentialPlayoutBuilder(
|
||||
PlayoutBuildMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
//schedulingEngine.WithMode(mode);
|
||||
//schedulingEngine.WithSeed(playout.Seed);
|
||||
|
||||
PlayoutBuildResult result = PlayoutBuildResult.Empty;
|
||||
|
||||
if (!localFileSystem.FileExists(playout.ScheduleFile))
|
||||
@@ -114,6 +119,8 @@ public class SequentialPlayoutBuilder(
|
||||
// InstructionIndex = 0
|
||||
};
|
||||
|
||||
//schedulingEngine.BuildBetween(start, finish);
|
||||
|
||||
// logger.LogDebug(
|
||||
// "Default yaml context from {Start} to {Finish}, instruction {Instruction}",
|
||||
// context.CurrentTime,
|
||||
@@ -123,6 +130,9 @@ public class SequentialPlayoutBuilder(
|
||||
// remove old items
|
||||
// importantly, this should not remove their history
|
||||
result = result with { RemoveBefore = start };
|
||||
//schedulingEngine.RemoveBefore(start);
|
||||
|
||||
//schedulingEngine.RestoreOrReset(Optional(playout.Anchor));
|
||||
|
||||
// load saved state
|
||||
if (mode is not PlayoutBuildMode.Reset)
|
||||
@@ -166,6 +176,22 @@ public class SequentialPlayoutBuilder(
|
||||
var applyHistoryHandler = new YamlPlayoutApplyHistoryHandler(enumeratorCache);
|
||||
foreach (YamlPlayoutContentItem contentItem in playoutDefinition.Content)
|
||||
{
|
||||
// if (!Enum.TryParse(contentItem.Order, true, out PlaybackOrder playbackOrder))
|
||||
// {
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// switch (contentItem)
|
||||
// {
|
||||
// case YamlPlayoutContentCollectionItem collectionItem:
|
||||
// // also applies history
|
||||
// await schedulingEngine.AddCollection(
|
||||
// collectionItem.Key,
|
||||
// collectionItem.Collection,
|
||||
// playbackOrder);
|
||||
// break;
|
||||
// }
|
||||
|
||||
await applyHistoryHandler.Handle(
|
||||
filteredHistory.ToImmutableList(),
|
||||
context,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<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="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}")"/>
|
||||
</ChildContent>
|
||||
</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 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 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.Refresh" Label="Reset All Playouts" OnClick="@ResetAllPlayouts"/>
|
||||
</MudMenu>
|
||||
|
||||
@@ -37,6 +37,7 @@ using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
||||
using ErsatzTV.Core.Scheduling.Engine;
|
||||
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -698,6 +699,7 @@ public class Startup
|
||||
services.AddScoped<IBlockPlayoutFillerBuilder, BlockPlayoutFillerBuilder>();
|
||||
services.AddScoped<ISequentialPlayoutBuilder, SequentialPlayoutBuilder>();
|
||||
services.AddScoped<IScriptedPlayoutBuilder, ScriptedPlayoutBuilder>();
|
||||
services.AddScoped<ISchedulingEngine, SchedulingEngine>();
|
||||
services.AddScoped<IExternalJsonPlayoutBuilder, ExternalJsonPlayoutBuilder>();
|
||||
services.AddScoped<IPlayoutTimeShifter, PlayoutTimeShifter>();
|
||||
services.AddScoped<IImageCache, ImageCache>();
|
||||
|
||||
Reference in New Issue
Block a user