add yaml playout pre_roll instruction (#2228)
* add yaml playout pre_roll instruction * add basic yaml validation * validate all yaml playout content items * fix yaml to json conversion * update changelog
This commit is contained in:
@@ -7,6 +7,22 @@ insert_final_newline=false
|
||||
indent_style=space
|
||||
indent_size=4
|
||||
|
||||
[*.json]
|
||||
ij_json_array_wrapping = normal
|
||||
ij_json_keep_blank_lines_in_code = 0
|
||||
ij_json_keep_indents_on_empty_lines = false
|
||||
ij_json_keep_line_breaks = true
|
||||
ij_json_keep_trailing_comma = false
|
||||
ij_json_object_wrapping = normal
|
||||
ij_json_property_alignment = do_not_align
|
||||
ij_json_space_after_colon = true
|
||||
ij_json_space_after_comma = true
|
||||
ij_json_space_before_colon = false
|
||||
ij_json_space_before_comma = false
|
||||
ij_json_spaces_within_braces = true
|
||||
ij_json_spaces_within_brackets = true
|
||||
ij_json_wrap_long_lines = false
|
||||
|
||||
# Microsoft .NET properties
|
||||
csharp_new_line_before_members_in_object_initializers=false
|
||||
csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion
|
||||
|
||||
@@ -12,6 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Embedded image subtitles
|
||||
- Embedded text subtitles that have already been extracted by ETV
|
||||
- Add light mode and light/dark mode toggle to app bar
|
||||
- YAML playout: add `pre_roll` instruction to enable and disable a pre-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic pre-roll for all content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic pre-roll in the playout
|
||||
- Add YAML playout validation (using JSON Schema)
|
||||
- `content` is fully validated
|
||||
- `sequence` is not validated yet
|
||||
- `reset` is not validated yet
|
||||
- `playout` is not validated yet
|
||||
|
||||
### Fixed
|
||||
- Fix app startup with MySql/MariaDB
|
||||
|
||||
@@ -10,6 +10,7 @@ public class PlayoutAnchor
|
||||
public bool InDurationFiller { get; set; }
|
||||
public int NextGuideGroup { get; set; }
|
||||
public int NextInstructionIndex { get; set; }
|
||||
public string Context { get; set; }
|
||||
|
||||
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Scheduling;
|
||||
|
||||
public interface IYamlScheduleValidator
|
||||
{
|
||||
Task<bool> ValidateSchedule(string yaml);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public interface IYamlPlayoutHandler
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
|
||||
@@ -12,6 +13,7 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -30,6 +32,13 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
|
||||
{
|
||||
for (var i = 0; i < enumerator.Count; i++)
|
||||
{
|
||||
foreach (string preRollSequence in context.GetPreRollSequence())
|
||||
{
|
||||
context.PushFillerKind(FillerKind.PreRoll);
|
||||
await executeSequence(preRollSequence);
|
||||
context.PopFillerKind();
|
||||
}
|
||||
|
||||
foreach (MediaItem mediaItem in enumerator.Current)
|
||||
{
|
||||
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
|
||||
@@ -42,7 +51,7 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
|
||||
Finish = context.CurrentTime.UtcDateTime + itemDuration,
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = itemDuration,
|
||||
FillerKind = GetFillerKind(all),
|
||||
FillerKind = GetFillerKind(all, context),
|
||||
CustomTitle = string.IsNullOrWhiteSpace(all.CustomTitle) ? null : all.CustomTitle,
|
||||
DisableWatermarks = all.DisableWatermarks,
|
||||
//PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
|
||||
|
||||
@@ -16,6 +16,7 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache)
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -147,15 +148,19 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache)
|
||||
return version.Duration;
|
||||
}
|
||||
|
||||
protected static FillerKind GetFillerKind(YamlPlayoutInstruction instruction)
|
||||
protected static FillerKind GetFillerKind(YamlPlayoutInstruction instruction, YamlPlayoutContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instruction.FillerKind))
|
||||
if (!string.IsNullOrWhiteSpace(instruction.FillerKind) &&
|
||||
Enum.TryParse(instruction.FillerKind, true, out FillerKind result))
|
||||
{
|
||||
return FillerKind.None;
|
||||
return result;
|
||||
}
|
||||
|
||||
return Enum.TryParse(instruction.FillerKind, true, out FillerKind result)
|
||||
? result
|
||||
: FillerKind.None;
|
||||
foreach (FillerKind fillerKind in context.GetFillerKind())
|
||||
{
|
||||
return fillerKind;
|
||||
}
|
||||
|
||||
return FillerKind.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
|
||||
@@ -13,6 +14,7 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -55,6 +57,13 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
|
||||
|
||||
for (var i = 0; i < countValue; i++)
|
||||
{
|
||||
foreach (string preRollSequence in context.GetPreRollSequence())
|
||||
{
|
||||
context.PushFillerKind(FillerKind.PreRoll);
|
||||
await executeSequence(preRollSequence);
|
||||
context.PopFillerKind();
|
||||
}
|
||||
|
||||
foreach (MediaItem mediaItem in enumerator.Current)
|
||||
{
|
||||
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
|
||||
@@ -67,7 +76,7 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
|
||||
Finish = context.CurrentTime.UtcDateTime + itemDuration,
|
||||
InPoint = TimeSpan.Zero,
|
||||
OutPoint = itemDuration,
|
||||
FillerKind = GetFillerKind(count),
|
||||
FillerKind = GetFillerKind(count, context),
|
||||
CustomTitle = string.IsNullOrWhiteSpace(count.CustomTitle) ? null : count.CustomTitle,
|
||||
DisableWatermarks = count.DisableWatermarks,
|
||||
//PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
|
||||
|
||||
@@ -14,6 +14,7 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -50,7 +51,7 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
|
||||
|
||||
foreach (IMediaCollectionEnumerator enumerator in maybeEnumerator)
|
||||
{
|
||||
context.CurrentTime = Schedule(
|
||||
context.CurrentTime = await Schedule(
|
||||
context,
|
||||
instruction.Content,
|
||||
duration.Fallback,
|
||||
@@ -59,11 +60,12 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
|
||||
duration.DiscardAttempts,
|
||||
duration.Trim,
|
||||
duration.OfflineTail,
|
||||
GetFillerKind(duration),
|
||||
GetFillerKind(duration, context),
|
||||
duration.CustomTitle,
|
||||
duration.DisableWatermarks,
|
||||
enumerator,
|
||||
fallbackEnumerator,
|
||||
executeSequence,
|
||||
logger);
|
||||
|
||||
return true;
|
||||
@@ -72,7 +74,7 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static DateTimeOffset Schedule(
|
||||
protected static async Task<DateTimeOffset> Schedule(
|
||||
YamlPlayoutContext context,
|
||||
string contentKey,
|
||||
string fallbackContentKey,
|
||||
@@ -86,12 +88,26 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
|
||||
bool disableWatermarks,
|
||||
IMediaCollectionEnumerator enumerator,
|
||||
Option<IMediaCollectionEnumerator> fallbackEnumerator,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger)
|
||||
{
|
||||
var done = false;
|
||||
TimeSpan remainingToFill = targetTime - context.CurrentTime;
|
||||
while (!done && enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero)
|
||||
{
|
||||
foreach (string preRollSequence in context.GetPreRollSequence())
|
||||
{
|
||||
context.PushFillerKind(FillerKind.PreRoll);
|
||||
await executeSequence(preRollSequence);
|
||||
context.PopFillerKind();
|
||||
|
||||
remainingToFill = targetTime - context.CurrentTime;
|
||||
if (remainingToFill <= TimeSpan.Zero)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MediaItem mediaItem in enumerator.Current)
|
||||
{
|
||||
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
|
||||
|
||||
@@ -11,6 +11,7 @@ public class YamlPlayoutEpgGroupHandler : IYamlPlayoutHandler
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ public class YamlPlayoutPadToNextHandler(EnumeratorCache enumeratorCache) : Yaml
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -54,7 +55,7 @@ public class YamlPlayoutPadToNextHandler(EnumeratorCache enumeratorCache) : Yaml
|
||||
|
||||
foreach (IMediaCollectionEnumerator enumerator in maybeEnumerator)
|
||||
{
|
||||
context.CurrentTime = Schedule(
|
||||
context.CurrentTime = await Schedule(
|
||||
context,
|
||||
padToNext.Content,
|
||||
padToNext.Fallback,
|
||||
@@ -63,11 +64,12 @@ public class YamlPlayoutPadToNextHandler(EnumeratorCache enumeratorCache) : Yaml
|
||||
padToNext.DiscardAttempts,
|
||||
padToNext.Trim,
|
||||
true,
|
||||
GetFillerKind(padToNext),
|
||||
GetFillerKind(padToNext, context),
|
||||
padToNext.CustomTitle,
|
||||
padToNext.DisableWatermarks,
|
||||
enumerator,
|
||||
fallbackEnumerator,
|
||||
executeSequence,
|
||||
logger);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -11,6 +11,7 @@ public class YamlPlayoutPadUntilHandler(EnumeratorCache enumeratorCache) : YamlP
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -73,7 +74,7 @@ public class YamlPlayoutPadUntilHandler(EnumeratorCache enumeratorCache) : YamlP
|
||||
|
||||
foreach (IMediaCollectionEnumerator enumerator in maybeEnumerator)
|
||||
{
|
||||
context.CurrentTime = Schedule(
|
||||
context.CurrentTime = await Schedule(
|
||||
context,
|
||||
padUntil.Content,
|
||||
padUntil.Fallback,
|
||||
@@ -82,11 +83,12 @@ public class YamlPlayoutPadUntilHandler(EnumeratorCache enumeratorCache) : YamlP
|
||||
padUntil.DiscardAttempts,
|
||||
padUntil.Trim,
|
||||
padUntil.OfflineTail,
|
||||
GetFillerKind(padUntil),
|
||||
GetFillerKind(padUntil, context),
|
||||
padUntil.CustomTitle,
|
||||
padUntil.DisableWatermarks,
|
||||
enumerator,
|
||||
fallbackEnumerator,
|
||||
executeSequence,
|
||||
logger);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
|
||||
|
||||
public class YamlPlayoutPreRollHandler : IYamlPlayoutHandler
|
||||
{
|
||||
public bool Reset => false;
|
||||
|
||||
public Task<bool> Handle(
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (instruction is not YamlPreRollInstruction preRoll)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (preRoll.PreRoll && !string.IsNullOrWhiteSpace(preRoll.Sequence))
|
||||
{
|
||||
context.SetPreRollSequence(preRoll.Sequence);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.ClearPreRollSequence();
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public class YamlPlayoutRepeatHandler : IYamlPlayoutHandler
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ public class YamlPlayoutSkipItemsHandler(EnumeratorCache enumeratorCache) : IYam
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ public class YamlPlayoutSkipToItemHandler(EnumeratorCache enumeratorCache) : IYa
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ public class YamlPlayoutWaitUntilHandler : IYamlPlayoutHandler
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) :
|
||||
YamlPlayoutContext context,
|
||||
YamlPlayoutInstruction instruction,
|
||||
PlayoutBuildMode mode,
|
||||
Func<string, Task> executeSequence,
|
||||
ILogger<YamlPlayoutBuilder> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models;
|
||||
|
||||
public class YamlPreRollInstruction : YamlPlayoutInstruction
|
||||
{
|
||||
[YamlMember(Alias = "pre_roll", ApplyNamingConventions = false)]
|
||||
public bool PreRoll { get; set; }
|
||||
|
||||
public string Sequence { get; set; }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Handlers;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
@@ -18,6 +19,7 @@ public class YamlPlayoutBuilder(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
IChannelRepository channelRepository,
|
||||
IYamlScheduleValidator yamlScheduleValidator,
|
||||
ILogger<YamlPlayoutBuilder> logger)
|
||||
: IYamlPlayoutBuilder
|
||||
{
|
||||
@@ -29,7 +31,15 @@ public class YamlPlayoutBuilder(
|
||||
return playout;
|
||||
}
|
||||
|
||||
YamlPlayoutDefinition playoutDefinition = await LoadYamlDefinition(playout, cancellationToken);
|
||||
Option<YamlPlayoutDefinition> maybePlayoutDefinition = await LoadYamlDefinition(playout, cancellationToken);
|
||||
if (maybePlayoutDefinition.IsNone)
|
||||
{
|
||||
logger.LogWarning("YAML playout file {File} is invalid; aborting.", playout.TemplateFile);
|
||||
return playout;
|
||||
}
|
||||
|
||||
// using ValueUnsafe to avoid nesting
|
||||
YamlPlayoutDefinition playoutDefinition = maybePlayoutDefinition.ValueUnsafe();
|
||||
|
||||
DateTimeOffset start = DateTimeOffset.Now;
|
||||
|
||||
@@ -62,12 +72,7 @@ public class YamlPlayoutBuilder(
|
||||
{
|
||||
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;
|
||||
context.Reset(prevAnchor, start);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -125,7 +130,13 @@ public class YamlPlayoutBuilder(
|
||||
}
|
||||
else
|
||||
{
|
||||
await handler.Handle(context, instruction, mode, logger, cancellationToken);
|
||||
await handler.Handle(
|
||||
context,
|
||||
instruction,
|
||||
mode,
|
||||
_ => Task.CompletedTask,
|
||||
logger,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,10 +178,20 @@ public class YamlPlayoutBuilder(
|
||||
|
||||
foreach (IYamlPlayoutHandler handler in maybeHandler)
|
||||
{
|
||||
if (!await handler.Handle(context, instruction, mode, logger, cancellationToken))
|
||||
if (!await handler.Handle(context, instruction, mode, ExecuteSequenceLocal, logger, cancellationToken))
|
||||
{
|
||||
logger.LogInformation("YAML playout instruction handler failed");
|
||||
}
|
||||
|
||||
continue;
|
||||
|
||||
async Task ExecuteSequenceLocal(string sequence) => await ExecuteSequence(
|
||||
handlers,
|
||||
enumeratorCache,
|
||||
mode,
|
||||
context,
|
||||
sequence,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (!instruction.ChangesIndex)
|
||||
@@ -191,8 +212,7 @@ public class YamlPlayoutBuilder(
|
||||
var anchor = new PlayoutAnchor
|
||||
{
|
||||
NextStart = maxTime,
|
||||
NextInstructionIndex = context.InstructionIndex,
|
||||
NextGuideGroup = context.PeekNextGuideGroup()
|
||||
Context = context.Serialize()
|
||||
};
|
||||
|
||||
context.AdvanceGuideGroup();
|
||||
@@ -207,6 +227,37 @@ public class YamlPlayoutBuilder(
|
||||
return playout;
|
||||
}
|
||||
|
||||
private async Task ExecuteSequence(
|
||||
Dictionary<YamlPlayoutInstruction, IYamlPlayoutHandler> handlers,
|
||||
EnumeratorCache enumeratorCache,
|
||||
PlayoutBuildMode mode,
|
||||
YamlPlayoutContext context,
|
||||
string sequence,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sequenceInstructions = context.Definition.Sequence
|
||||
.Filter(s => s.Key == sequence)
|
||||
.HeadOrNone()
|
||||
.Map(s => s.Items)
|
||||
.Flatten()
|
||||
.ToList();
|
||||
|
||||
foreach (YamlPlayoutInstruction instruction in sequenceInstructions)
|
||||
{
|
||||
//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, _ => Task.CompletedTask, logger, cancellationToken))
|
||||
{
|
||||
logger.LogInformation("YAML playout instruction handler failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DetectCycle(YamlPlayoutDefinition definition)
|
||||
{
|
||||
var graph = new AdjGraph();
|
||||
@@ -292,6 +343,7 @@ public class YamlPlayoutBuilder(
|
||||
YamlPlayoutEpgGroupInstruction => new YamlPlayoutEpgGroupHandler(),
|
||||
YamlPlayoutWatermarkInstruction => new YamlPlayoutWatermarkHandler(channelRepository),
|
||||
YamlPlayoutShuffleSequenceInstruction => new YamlPlayoutShuffleSequenceHandler(),
|
||||
YamlPreRollInstruction => new YamlPlayoutPreRollHandler(),
|
||||
|
||||
YamlPlayoutSkipItemsInstruction => new YamlPlayoutSkipItemsHandler(enumeratorCache),
|
||||
YamlPlayoutSkipToItemInstruction => new YamlPlayoutSkipToItemHandler(enumeratorCache),
|
||||
@@ -314,11 +366,15 @@ public class YamlPlayoutBuilder(
|
||||
return Optional(handler);
|
||||
}
|
||||
|
||||
private async Task<YamlPlayoutDefinition> LoadYamlDefinition(Playout playout, CancellationToken cancellationToken)
|
||||
private async Task<Option<YamlPlayoutDefinition>> LoadYamlDefinition(Playout playout, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
string yaml = await File.ReadAllTextAsync(playout.TemplateFile, cancellationToken);
|
||||
if (await yamlScheduleValidator.ValidateSchedule(yaml) == false)
|
||||
{
|
||||
return Option<YamlPlayoutDefinition>.None;
|
||||
}
|
||||
|
||||
IDeserializer deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
@@ -346,6 +402,7 @@ public class YamlPlayoutBuilder(
|
||||
{ "watermark", typeof(YamlPlayoutWatermarkInstruction) },
|
||||
{ "pad_to_next", typeof(YamlPlayoutPadToNextInstruction) },
|
||||
{ "pad_until", typeof(YamlPlayoutPadUntilInstruction) },
|
||||
{ "pre_roll", typeof(YamlPreRollInstruction) },
|
||||
{ "repeat", typeof(YamlPlayoutRepeatInstruction) },
|
||||
{ "sequence", typeof(YamlPlayoutSequenceInstruction) },
|
||||
{ "shuffle_sequence", typeof(YamlPlayoutShuffleSequenceInstruction) },
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling.YamlScheduling;
|
||||
|
||||
public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definition, int guideGroup)
|
||||
{
|
||||
private static readonly JsonSerializerSettings JsonSettings = new()
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
private readonly System.Collections.Generic.HashSet<int> _visitedInstructions = [];
|
||||
private int _guideGroup = guideGroup;
|
||||
private bool _guideGroupLocked;
|
||||
private int _instructionIndex;
|
||||
private Option<int> _channelWatermarkId;
|
||||
private Option<string> _preRollSequence;
|
||||
private Stack<FillerKind> _fillerKind = new();
|
||||
|
||||
public Playout Playout { get; } = playout;
|
||||
|
||||
@@ -82,4 +91,88 @@ public class YamlPlayoutContext(Playout playout, YamlPlayoutDefinition definitio
|
||||
}
|
||||
|
||||
public Option<int> GetChannelWatermarkId() => _channelWatermarkId;
|
||||
|
||||
public void SetPreRollSequence(string sequence)
|
||||
{
|
||||
_preRollSequence = sequence;
|
||||
}
|
||||
|
||||
public void ClearPreRollSequence()
|
||||
{
|
||||
_preRollSequence = Option<string>.None;
|
||||
}
|
||||
|
||||
public Option<string> GetPreRollSequence() => _preRollSequence;
|
||||
|
||||
public void PushFillerKind(FillerKind fillerKind)
|
||||
{
|
||||
_fillerKind.Push(fillerKind);
|
||||
}
|
||||
|
||||
public void PopFillerKind() => _fillerKind.Pop();
|
||||
|
||||
public Option<FillerKind> GetFillerKind() =>
|
||||
_fillerKind.TryPeek(out FillerKind fillerKind) ? fillerKind : Option<FillerKind>.None;
|
||||
|
||||
public string Serialize()
|
||||
{
|
||||
int? channelWatermarkId = null;
|
||||
foreach (int id in _channelWatermarkId)
|
||||
{
|
||||
channelWatermarkId = id;
|
||||
}
|
||||
|
||||
string preRollSequence = null;
|
||||
foreach (string sequence in _preRollSequence)
|
||||
{
|
||||
preRollSequence = sequence;
|
||||
}
|
||||
|
||||
var state = new State(_instructionIndex, _guideGroup, _guideGroupLocked, channelWatermarkId, preRollSequence);
|
||||
return JsonConvert.SerializeObject(state, Formatting.None, JsonSettings);
|
||||
}
|
||||
|
||||
public void Reset(PlayoutAnchor anchor, DateTimeOffset start)
|
||||
{
|
||||
CurrentTime = new DateTimeOffset(anchor.NextStart.ToLocalTime(), start.Offset);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(anchor.Context))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
State state = JsonConvert.DeserializeObject<State>(anchor.Context);
|
||||
|
||||
foreach (int instructionIndex in Optional(state.InstructionIndex))
|
||||
{
|
||||
_instructionIndex = instructionIndex;
|
||||
}
|
||||
|
||||
foreach (int guideGroup in Optional(state.GuideGroup))
|
||||
{
|
||||
_guideGroup = guideGroup;
|
||||
}
|
||||
|
||||
foreach (bool guideGroupLocked in Optional(state.GuideGroupLocked))
|
||||
{
|
||||
_guideGroupLocked = guideGroupLocked;
|
||||
}
|
||||
|
||||
foreach (int channelWatermarkId in Optional(state.ChannelWatermarkId))
|
||||
{
|
||||
_channelWatermarkId = channelWatermarkId;
|
||||
}
|
||||
|
||||
foreach (string preRollSequence in Optional(state.PreRollSequence))
|
||||
{
|
||||
_preRollSequence = preRollSequence;
|
||||
}
|
||||
}
|
||||
|
||||
public record State(
|
||||
int? InstructionIndex,
|
||||
int? GuideGroup,
|
||||
bool? GuideGroupLocked,
|
||||
int? ChannelWatermarkId,
|
||||
string PreRollSequence);
|
||||
}
|
||||
|
||||
Generated
+6148
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_PlayoutAnchorContext : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Context",
|
||||
table: "PlayoutAnchor",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Context",
|
||||
table: "PlayoutAnchor");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4510,6 +4510,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b1.Property<int>("PlayoutId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b1.Property<string>("Context")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b1.Property<DateTime?>("DurationFinish")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
|
||||
Generated
+5985
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_PlayoutAnchorContext : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Context",
|
||||
table: "PlayoutAnchor",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Context",
|
||||
table: "PlayoutAnchor");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4347,6 +4347,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b1.Property<int>("PlayoutId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Context")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<DateTime?>("DurationFinish")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json.Schema" Version="4.0.1" />
|
||||
<PackageReference Include="Refit" Version="8.0.0" />
|
||||
<PackageReference Include="Refit.Newtonsoft.Json" Version="8.0.0" />
|
||||
<PackageReference Include="Refit.Xml" Version="8.0.0" />
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Schema;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Scheduling;
|
||||
|
||||
public class YamlScheduleValidator(ILogger<YamlScheduleValidator> logger) : IYamlScheduleValidator
|
||||
{
|
||||
public async Task<bool> ValidateSchedule(string yaml)
|
||||
{
|
||||
try
|
||||
{
|
||||
string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "yaml-playout.schema.json");
|
||||
using StreamReader sr = File.OpenText(schemaFileName);
|
||||
await using var reader = new JsonTextReader(sr);
|
||||
var schema = JSchema.Load(reader);
|
||||
|
||||
using var textReader = new StringReader(yaml);
|
||||
var yamlStream = new YamlStream();
|
||||
yamlStream.Load(textReader);
|
||||
var schedule = JObject.Parse(Convert(yamlStream));
|
||||
|
||||
if (!schedule.IsValid(schema, out IList<string> errorMessages))
|
||||
{
|
||||
logger.LogWarning("Failed to validate YAML schedule definition: {ErrorMessages}", errorMessages);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Unexpected error while validating YAML schedule definition");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Convert(YamlStream yamlStream)
|
||||
{
|
||||
var visitor = new YamlToJsonVisitor();
|
||||
yamlStream.Accept(visitor);
|
||||
return visitor.JsonString;
|
||||
}
|
||||
|
||||
private sealed class YamlToJsonVisitor : IYamlVisitor
|
||||
{
|
||||
private readonly JsonSerializerOptions _options = new() { WriteIndented = false, };
|
||||
private object _currentValue;
|
||||
|
||||
public string JsonString => JsonSerializer.Serialize(_currentValue, _options);
|
||||
|
||||
public void Visit(YamlScalarNode scalar)
|
||||
{
|
||||
var value = scalar.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
_currentValue = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse in order of most specific to most general
|
||||
if (value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_currentValue = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.Equals("false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_currentValue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intResult))
|
||||
{
|
||||
_currentValue = intResult;
|
||||
return;
|
||||
}
|
||||
|
||||
if (double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var doubleResult))
|
||||
{
|
||||
_currentValue = doubleResult;
|
||||
return;
|
||||
}
|
||||
|
||||
_currentValue = value;
|
||||
}
|
||||
|
||||
public void Visit(YamlSequenceNode sequence)
|
||||
{
|
||||
var array = new List<object>();
|
||||
foreach (var node in sequence.Children)
|
||||
{
|
||||
node.Accept(this);
|
||||
array.Add(_currentValue);
|
||||
}
|
||||
|
||||
_currentValue = array;
|
||||
}
|
||||
|
||||
public void Visit(YamlMappingNode mapping)
|
||||
{
|
||||
Dictionary<string, object> dict = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in mapping.Children)
|
||||
{
|
||||
var key = entry.Key switch
|
||||
{
|
||||
YamlScalarNode scalar => scalar.Value,
|
||||
_ => entry.Key.ToString(),
|
||||
};
|
||||
|
||||
entry.Value.Accept(this);
|
||||
dict[key!] = _currentValue;
|
||||
}
|
||||
|
||||
_currentValue = dict;
|
||||
}
|
||||
|
||||
public void Visit(YamlDocument document) => document.RootNode.Accept(this);
|
||||
public void Visit(YamlStream stream) => stream.Documents[0].RootNode.Accept(this);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@
|
||||
<EmbeddedResource Include="Resources\Templates\_musicVideo.sbntxt" />
|
||||
<EmbeddedResource Include="Resources\Templates\_otherVideo.sbntxt" />
|
||||
<EmbeddedResource Include="Resources\Templates\_song.sbntxt" />
|
||||
<EmbeddedResource Include="Resources\yaml-playout.schema.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://ersatztv.org/yaml-playout.schema.json",
|
||||
"title": "YAML Playout",
|
||||
"description": "An ErsatzTV YAML playout definition",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Content definitions",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/showContent" },
|
||||
{ "$ref": "#/$defs/searchContent" },
|
||||
{ "$ref": "#/$defs/collectionContent" },
|
||||
{ "$ref": "#/$defs/multiCollectionContent" },
|
||||
{ "$ref": "#/$defs/smartCollectionContent" },
|
||||
{ "$ref": "#/$defs/playlistContent" },
|
||||
{ "$ref": "#/$defs/marathonContent" }
|
||||
]
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"sequence": {
|
||||
"description": "Sequence definitions",
|
||||
"type": "array"
|
||||
},
|
||||
"reset": {
|
||||
"description": "Reset instructions",
|
||||
"type": "array"
|
||||
},
|
||||
"playout": {
|
||||
"description": "Playout instructions",
|
||||
"type": "array",
|
||||
"minItems": 1
|
||||
}
|
||||
},
|
||||
"required": [ "content", "playout" ],
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"showContent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"show": { "type": "null" },
|
||||
"key": { "type": "string" },
|
||||
"guids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"value": { "type": "string" }
|
||||
},
|
||||
"required": [ "source", "value" ],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"order": { "enum": [ "chronological", "shuffle" ] }
|
||||
},
|
||||
"required": [ "show", "key", "guids", "order" ],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"searchContent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search": { "type": "null" },
|
||||
"key": { "type": "string" },
|
||||
"query": { "type": "string" },
|
||||
"order": { "enum": [ "chronological", "shuffle" ] }
|
||||
},
|
||||
"required": [ "search", "key", "query", "order" ],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"collectionContent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": { "type": "string" },
|
||||
"key": { "type": "string" },
|
||||
"order": { "enum": [ "chronological", "shuffle" ] }
|
||||
},
|
||||
"required": [ "collection", "key", "order" ],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"multiCollectionContent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"multi_collection": { "type": "string" },
|
||||
"key": { "type": "string" },
|
||||
"order": { "enum": [ "chronological", "shuffle" ] }
|
||||
},
|
||||
"required": [ "multi_collection", "key", "order" ],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"smartCollectionContent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"smart_collection": { "type": "string" },
|
||||
"key": { "type": "string" },
|
||||
"order": { "enum": [ "chronological", "shuffle" ] }
|
||||
},
|
||||
"required": [ "smart_collection", "key", "order" ],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"playlistContent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playlist": { "type": "string" },
|
||||
"playlist_group": { "type": "string" },
|
||||
"key": { "type": "string" }
|
||||
},
|
||||
"required": [ "playlist", "playlist_group", "key" ],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"marathonContent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"marathon": { "type": "null" },
|
||||
"key": { "type": "string" },
|
||||
"guids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"value": { "type": "string" }
|
||||
},
|
||||
"required": [ "source", "value" ],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"searches": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"group_by": { "enum": [ "show", "season", "artist", "album" ] },
|
||||
"item_order": { "enum": [ "chronological", "shuffle" ] },
|
||||
"play_all_items": { "type": "boolean" },
|
||||
"shuffle_groups": { "type": "boolean" }
|
||||
},
|
||||
"required": [ "marathon", "key" ],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public class ResourceExtractorService : BackgroundService
|
||||
await ExtractResource(assembly, "song_progress_overlay.png", stoppingToken);
|
||||
await ExtractResource(assembly, "song_progress_overlay_43.png", stoppingToken);
|
||||
await ExtractResource(assembly, "ErsatzTV.png", stoppingToken);
|
||||
await ExtractResource(assembly, "yaml-playout.schema.json", stoppingToken);
|
||||
|
||||
await ExtractFontResource(assembly, "Sen.ttf", stoppingToken);
|
||||
await ExtractFontResource(assembly, "Roboto-Regular.ttf", stoppingToken);
|
||||
|
||||
@@ -730,6 +730,7 @@ public class Startup
|
||||
services.AddScoped<IJellyfinSecretStore, JellyfinSecretStore>();
|
||||
services.AddScoped<IEmbySecretStore, EmbySecretStore>();
|
||||
services.AddScoped<IScriptEngine, ScriptEngine>();
|
||||
services.AddScoped<IYamlScheduleValidator, YamlScheduleValidator>();
|
||||
|
||||
services.AddScoped<PlexEtag>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user