mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
395 lines
15 KiB
395 lines
15 KiB
using System.Collections.Immutable; |
|
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.Domain.Scheduling; |
|
using ErsatzTV.Core.Interfaces.Metadata; |
|
using ErsatzTV.Core.Interfaces.Repositories; |
|
using ErsatzTV.Core.Interfaces.Scheduling; |
|
using ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; |
|
using ErsatzTV.Core.Scheduling.YamlScheduling.Models; |
|
using ErsatzTV.Core.Search; |
|
using Microsoft.Extensions.Logging; |
|
using YamlDotNet.Serialization; |
|
using YamlDotNet.Serialization.NamingConventions; |
|
|
|
namespace ErsatzTV.Core.Scheduling.YamlScheduling; |
|
|
|
public class YamlPlayoutBuilder( |
|
ILocalFileSystem localFileSystem, |
|
IConfigElementRepository configElementRepository, |
|
IMediaCollectionRepository mediaCollectionRepository, |
|
ILogger<YamlPlayoutBuilder> logger) |
|
: IYamlPlayoutBuilder |
|
{ |
|
public async Task<Playout> Build(Playout playout, PlayoutBuildMode mode, CancellationToken cancellationToken) |
|
{ |
|
if (!localFileSystem.FileExists(playout.TemplateFile)) |
|
{ |
|
logger.LogWarning("YAML playout file {File} does not exist; aborting.", playout.TemplateFile); |
|
return playout; |
|
} |
|
|
|
YamlPlayoutDefinition playoutDefinition = await LoadYamlDefinition(playout, cancellationToken); |
|
|
|
DateTimeOffset start = DateTimeOffset.Now; |
|
|
|
int daysToBuild = await GetDaysToBuild(); |
|
DateTimeOffset finish = start.AddDays(daysToBuild); |
|
|
|
Dictionary<YamlPlayoutInstruction, IYamlPlayoutHandler> handlers = new(); |
|
var enumeratorCache = new EnumeratorCache(mediaCollectionRepository, logger); |
|
|
|
var context = new YamlPlayoutContext(playout, playoutDefinition, guideGroup: 1) |
|
{ |
|
CurrentTime = start |
|
|
|
// no need to init default value and throw off visited count |
|
// InstructionIndex = 0 |
|
}; |
|
|
|
// logger.LogDebug( |
|
// "Default yaml context from {Start} to {Finish}, instruction {Instruction}", |
|
// context.CurrentTime, |
|
// finish, |
|
// context.InstructionIndex); |
|
|
|
// remove old items |
|
// importantly, this should not remove their history |
|
playout.Items.RemoveAll(i => i.FinishOffset < start); |
|
|
|
// load saved state |
|
if (mode is not PlayoutBuildMode.Reset) |
|
{ |
|
foreach (PlayoutAnchor prevAnchor in Optional(playout.Anchor)) |
|
{ |
|
// TODO: does this matter? |
|
//context.GuideGroup = prevAnchor.NextGuideGroup; |
|
|
|
context.CurrentTime = new DateTimeOffset(prevAnchor.NextStart.ToLocalTime(), start.Offset); |
|
|
|
context.InstructionIndex = prevAnchor.NextInstructionIndex; |
|
} |
|
} |
|
else |
|
{ |
|
// reset (remove items and "currently active" history) |
|
|
|
// for testing |
|
// start = start.AddHours(-2); |
|
|
|
// erase items, not history |
|
playout.Items.Clear(); |
|
|
|
// remove any future or "currently active" history items |
|
// this prevents "walking" the playout forward by repeatedly resetting |
|
var toRemove = new List<PlayoutHistory>(); |
|
toRemove.AddRange(playout.PlayoutHistory.Filter(h => h.When > start.UtcDateTime || h.When <= start.UtcDateTime && h.Finish >= start.UtcDateTime)); |
|
foreach (PlayoutHistory history in toRemove) |
|
{ |
|
playout.PlayoutHistory.Remove(history); |
|
} |
|
} |
|
|
|
// logger.LogDebug( |
|
// "Saved yaml context from {Start} to {Finish}, instruction {Instruction}", |
|
// context.CurrentTime, |
|
// finish, |
|
// context.InstructionIndex); |
|
|
|
// apply all history |
|
var applyHistoryHandler = new YamlPlayoutApplyHistoryHandler(enumeratorCache); |
|
foreach (YamlPlayoutContentItem contentItem in playoutDefinition.Content) |
|
{ |
|
await applyHistoryHandler.Handle(context, contentItem, logger, cancellationToken); |
|
} |
|
|
|
if (mode is PlayoutBuildMode.Reset) |
|
{ |
|
// handle all on-reset instructions |
|
foreach (YamlPlayoutInstruction instruction in playoutDefinition.Reset) |
|
{ |
|
Option<IYamlPlayoutHandler> maybeHandler = GetHandlerForInstruction( |
|
handlers, |
|
enumeratorCache, |
|
instruction); |
|
|
|
foreach (IYamlPlayoutHandler handler in maybeHandler) |
|
{ |
|
if (!handler.Reset) |
|
{ |
|
logger.LogInformation( |
|
"Skipping unsupported reset instruction {Instruction}", |
|
instruction.GetType().Name); |
|
} |
|
else |
|
{ |
|
await handler.Handle(context, instruction, mode, logger, cancellationToken); |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (DetectCycle(context.Definition)) |
|
{ |
|
logger.LogError("YAML sequence contains a cycle; unable to build playout"); |
|
return playout; |
|
} |
|
|
|
var flattenCount = 0; |
|
while (context.Definition.Playout.Any(x => x is YamlPlayoutSequenceInstruction)) |
|
{ |
|
if (flattenCount > 100) |
|
{ |
|
logger.LogError( |
|
"YAML playout definition contains sequence nesting that is too deep; this introduces undefined behavior"); |
|
break; |
|
} |
|
|
|
FlattenSequences(context); |
|
flattenCount++; |
|
} |
|
|
|
// handle all playout instructions |
|
while (context.CurrentTime < finish) |
|
{ |
|
if (context.InstructionIndex >= playoutDefinition.Playout.Count) |
|
{ |
|
logger.LogInformation("Reached the end of the YAML playout definition; stopping"); |
|
break; |
|
} |
|
|
|
YamlPlayoutInstruction instruction = playoutDefinition.Playout[context.InstructionIndex]; |
|
//logger.LogDebug("Current playout instruction: {Instruction}", instruction.GetType().Name); |
|
|
|
Option<IYamlPlayoutHandler> maybeHandler = GetHandlerForInstruction(handlers, enumeratorCache, instruction); |
|
|
|
foreach (IYamlPlayoutHandler handler in maybeHandler) |
|
{ |
|
if (!await handler.Handle(context, instruction, mode, logger, cancellationToken)) |
|
{ |
|
logger.LogInformation("YAML playout instruction handler failed"); |
|
} |
|
} |
|
|
|
if (!instruction.ChangesIndex) |
|
{ |
|
//logger.LogDebug("Moving to next instruction"); |
|
context.InstructionIndex++; |
|
} |
|
} |
|
|
|
CleanUpHistory(playout, start); |
|
|
|
DateTime maxTime = context.CurrentTime.UtcDateTime; |
|
if (playout.Items.Count > 0) |
|
{ |
|
maxTime = playout.Items.Max(i => i.Finish); |
|
} |
|
|
|
var anchor = new PlayoutAnchor |
|
{ |
|
NextStart = maxTime, |
|
NextInstructionIndex = context.InstructionIndex, |
|
NextGuideGroup = context.PeekNextGuideGroup() |
|
}; |
|
|
|
context.AdvanceGuideGroup(); |
|
|
|
// logger.LogDebug( |
|
// "Saving yaml context at {Start}, instruction {Instruction}", |
|
// maxTime, |
|
// context.InstructionIndex); |
|
|
|
playout.Anchor = anchor; |
|
|
|
return playout; |
|
} |
|
|
|
private static bool DetectCycle(YamlPlayoutDefinition definition) |
|
{ |
|
var graph = new AdjGraph(); |
|
|
|
foreach (YamlPlayoutSequenceItem sequence in definition.Sequence) |
|
{ |
|
foreach (YamlPlayoutSequenceInstruction instruction in sequence.Items.OfType<YamlPlayoutSequenceInstruction>()) |
|
{ |
|
graph.AddEdge(sequence.Key, instruction.Sequence); |
|
} |
|
} |
|
|
|
return graph.HasAnyCycle(); |
|
} |
|
|
|
private async Task<int> GetDaysToBuild() => |
|
await configElementRepository |
|
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild) |
|
.IfNoneAsync(2); |
|
|
|
private static void FlattenSequences(YamlPlayoutContext context) |
|
{ |
|
var rawInstructions = context.Definition.Playout.ToImmutableList(); |
|
context.Definition.Playout.Clear(); |
|
|
|
foreach (YamlPlayoutInstruction instruction in rawInstructions) |
|
{ |
|
switch (instruction) |
|
{ |
|
case YamlPlayoutSequenceInstruction sequenceInstruction: |
|
IEnumerable<YamlPlayoutInstruction> sequenceInstructions = context.Definition.Sequence |
|
.Filter(s => s.Key == sequenceInstruction.Sequence) |
|
.HeadOrNone() |
|
.Map(s => s.Items) |
|
.Flatten() |
|
.ToList(); |
|
|
|
var sequenceGuid = Guid.NewGuid(); |
|
int repeat = sequenceInstruction.Repeat > 0 ? sequenceInstruction.Repeat : 1; |
|
|
|
for (var r = 0; r < repeat; r++) |
|
{ |
|
// insert all instructions from the sequence |
|
foreach (YamlPlayoutInstruction i in sequenceInstructions) |
|
{ |
|
// used for shuffling |
|
i.SequenceKey = sequenceInstruction.Sequence; |
|
i.SequenceGuid = sequenceGuid; |
|
|
|
// copy custom title |
|
if (!string.IsNullOrWhiteSpace(sequenceInstruction.CustomTitle)) |
|
{ |
|
i.CustomTitle = sequenceInstruction.CustomTitle; |
|
} |
|
|
|
context.Definition.Playout.Add(i); |
|
} |
|
} |
|
|
|
break; |
|
default: |
|
context.Definition.Playout.Add(instruction); |
|
break; |
|
} |
|
} |
|
} |
|
|
|
private static Option<IYamlPlayoutHandler> GetHandlerForInstruction( |
|
Dictionary<YamlPlayoutInstruction, IYamlPlayoutHandler> handlers, |
|
EnumeratorCache enumeratorCache, |
|
YamlPlayoutInstruction instruction) |
|
{ |
|
if (handlers.TryGetValue(instruction, out IYamlPlayoutHandler handler)) |
|
{ |
|
return Optional(handler); |
|
} |
|
|
|
handler = instruction switch |
|
{ |
|
YamlPlayoutRepeatInstruction => new YamlPlayoutRepeatHandler(), |
|
YamlPlayoutWaitUntilInstruction => new YamlPlayoutWaitUntilHandler(), |
|
YamlPlayoutEpgGroupInstruction => new YamlPlayoutEpgGroupHandler(), |
|
YamlPlayoutShuffleSequenceInstruction => new YamlPlayoutShuffleSequenceHandler(), |
|
|
|
YamlPlayoutSkipItemsInstruction => new YamlPlayoutSkipItemsHandler(enumeratorCache), |
|
YamlPlayoutSkipToItemInstruction => new YamlPlayoutSkipToItemHandler(enumeratorCache), |
|
|
|
// content handlers |
|
YamlPlayoutAllInstruction => new YamlPlayoutAllHandler(enumeratorCache), |
|
YamlPlayoutCountInstruction => new YamlPlayoutCountHandler(enumeratorCache), |
|
YamlPlayoutDurationInstruction => new YamlPlayoutDurationHandler(enumeratorCache), |
|
YamlPlayoutPadToNextInstruction => new YamlPlayoutPadToNextHandler(enumeratorCache), |
|
YamlPlayoutPadUntilInstruction => new YamlPlayoutPadUntilHandler(enumeratorCache), |
|
|
|
_ => null |
|
}; |
|
|
|
if (handler != null) |
|
{ |
|
handlers.Add(instruction, handler); |
|
} |
|
|
|
return Optional(handler); |
|
} |
|
|
|
private async Task<YamlPlayoutDefinition> LoadYamlDefinition(Playout playout, CancellationToken cancellationToken) |
|
{ |
|
try |
|
{ |
|
string yaml = await File.ReadAllTextAsync(playout.TemplateFile, cancellationToken); |
|
|
|
IDeserializer deserializer = new DeserializerBuilder() |
|
.WithNamingConvention(CamelCaseNamingConvention.Instance) |
|
.WithTypeDiscriminatingNodeDeserializer( |
|
o => |
|
{ |
|
var contentKeyMappings = new Dictionary<string, Type> |
|
{ |
|
{ "collection", typeof(YamlPlayoutContentCollectionItem) }, |
|
{ "marathon", typeof(YamlPlayoutContentMarathonItem) }, |
|
{ "multi_collection", typeof(YamlPlayoutContentMultiCollectionItem) }, |
|
{ "playlist", typeof(YamlPlayoutContentPlaylistItem) }, |
|
{ "search", typeof(YamlPlayoutContentSearchItem) }, |
|
{ "show", typeof(YamlPlayoutContentShowItem) }, |
|
{ "smart_collection", typeof(YamlPlayoutContentSmartCollectionItem) } |
|
}; |
|
|
|
o.AddUniqueKeyTypeDiscriminator<YamlPlayoutContentItem>(contentKeyMappings); |
|
|
|
var instructionKeyMappings = new Dictionary<string, Type> |
|
{ |
|
{ "all", typeof(YamlPlayoutAllInstruction) }, |
|
{ "count", typeof(YamlPlayoutCountInstruction) }, |
|
{ "duration", typeof(YamlPlayoutDurationInstruction) }, |
|
{ "epg_group", typeof(YamlPlayoutEpgGroupInstruction) }, |
|
{ "pad_to_next", typeof(YamlPlayoutPadToNextInstruction) }, |
|
{ "pad_until", typeof(YamlPlayoutPadUntilInstruction) }, |
|
{ "repeat", typeof(YamlPlayoutRepeatInstruction) }, |
|
{ "sequence", typeof(YamlPlayoutSequenceInstruction) }, |
|
{ "shuffle_sequence", typeof(YamlPlayoutShuffleSequenceInstruction) }, |
|
{ "skip_items", typeof(YamlPlayoutSkipItemsInstruction) }, |
|
{ "skip_to_item", typeof(YamlPlayoutSkipToItemInstruction) }, |
|
{ "wait_until", typeof(YamlPlayoutWaitUntilInstruction) } |
|
}; |
|
|
|
o.AddUniqueKeyTypeDiscriminator<YamlPlayoutInstruction>(instructionKeyMappings); |
|
}) |
|
.Build(); |
|
|
|
return deserializer.Deserialize<YamlPlayoutDefinition>(yaml); |
|
} |
|
catch (Exception ex) |
|
{ |
|
logger.LogWarning(ex, "Error loading YAML"); |
|
throw; |
|
} |
|
} |
|
|
|
private static void CleanUpHistory(Playout playout, DateTimeOffset start) |
|
{ |
|
var groups = new Dictionary<string, List<PlayoutHistory>>(); |
|
foreach (PlayoutHistory history in playout.PlayoutHistory) |
|
{ |
|
if (!groups.TryGetValue(history.Key, out List<PlayoutHistory> group)) |
|
{ |
|
group = []; |
|
groups[history.Key] = group; |
|
} |
|
|
|
group.Add(history); |
|
} |
|
|
|
foreach ((string _, List<PlayoutHistory> group) in groups) |
|
{ |
|
//logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count); |
|
|
|
// keep the most recent item from each history group that has already been played completely |
|
IEnumerable<PlayoutHistory> toDelete = group |
|
.Filter(h => h.Finish < start.UtcDateTime) |
|
.OrderByDescending(h => h.When) |
|
.Tail(); |
|
|
|
foreach (PlayoutHistory delete in toDelete) |
|
{ |
|
playout.PlayoutHistory.Remove(delete); |
|
} |
|
} |
|
} |
|
}
|
|
|