Browse Source

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
pull/2229/head
Jason Dove 5 months ago committed by GitHub
parent
commit
093abf7ad8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 16
      .editorconfig
  2. 8
      CHANGELOG.md
  3. 1
      ErsatzTV.Core/Domain/PlayoutAnchor.cs
  4. 6
      ErsatzTV.Core/Interfaces/Scheduling/IYamlScheduleValidator.cs
  5. 1
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/IYamlPlayoutHandler.cs
  6. 11
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs
  7. 17
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs
  8. 11
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs
  9. 22
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs
  10. 1
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutEpgGroupHandler.cs
  11. 6
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadToNextHandler.cs
  12. 6
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadUntilHandler.cs
  13. 34
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPreRollHandler.cs
  14. 1
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutRepeatHandler.cs
  15. 1
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs
  16. 1
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutSkipItemsHandler.cs
  17. 1
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutSkipToItemHandler.cs
  18. 1
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWaitUntilHandler.cs
  19. 1
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs
  20. 11
      ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPreRollInstruction.cs
  21. 81
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs
  22. 93
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs
  23. 6148
      ErsatzTV.Infrastructure.MySql/Migrations/20250801025332_Add_PlayoutAnchorContext.Designer.cs
  24. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20250801025332_Add_PlayoutAnchorContext.cs
  25. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  26. 5985
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250801025302_Add_PlayoutAnchorContext.Designer.cs
  27. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250801025302_Add_PlayoutAnchorContext.cs
  28. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  29. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  30. 131
      ErsatzTV.Infrastructure/Scheduling/YamlScheduleValidator.cs
  31. 1
      ErsatzTV/ErsatzTV.csproj
  32. 146
      ErsatzTV/Resources/yaml-playout.schema.json
  33. 1
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs
  34. 1
      ErsatzTV/Startup.cs

16
.editorconfig

@ -7,6 +7,22 @@ insert_final_newline=false @@ -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

8
CHANGELOG.md

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

1
ErsatzTV.Core/Domain/PlayoutAnchor.cs

@ -10,6 +10,7 @@ public class PlayoutAnchor @@ -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();

6
ErsatzTV.Core/Interfaces/Scheduling/IYamlScheduleValidator.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IYamlScheduleValidator
{
Task<bool> ValidateSchedule(string yaml);
}

1
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/IYamlPlayoutHandler.cs

@ -11,6 +11,7 @@ public interface IYamlPlayoutHandler @@ -11,6 +11,7 @@ public interface IYamlPlayoutHandler
YamlPlayoutContext context,
YamlPlayoutInstruction instruction,
PlayoutBuildMode mode,
Func<string, Task> executeSequence,
ILogger<YamlPlayoutBuilder> logger,
CancellationToken cancellationToken);
}

11
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutAllHandler.cs

@ -1,4 +1,5 @@ @@ -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 @@ -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 @@ -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 @@ -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,

17
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs

@ -16,6 +16,7 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache) @@ -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) @@ -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;
}
}

11
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs

@ -1,4 +1,5 @@ @@ -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 @@ -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 @@ -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 @@ -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,

22
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs

@ -14,6 +14,7 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP @@ -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 @@ -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 @@ -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 @@ -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 @@ -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);

1
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutEpgGroupHandler.cs

@ -11,6 +11,7 @@ public class YamlPlayoutEpgGroupHandler : IYamlPlayoutHandler @@ -11,6 +11,7 @@ public class YamlPlayoutEpgGroupHandler : IYamlPlayoutHandler
YamlPlayoutContext context,
YamlPlayoutInstruction instruction,
PlayoutBuildMode mode,
Func<string, Task> executeSequence,
ILogger<YamlPlayoutBuilder> logger,
CancellationToken cancellationToken)
{

6
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadToNextHandler.cs

@ -10,6 +10,7 @@ public class YamlPlayoutPadToNextHandler(EnumeratorCache enumeratorCache) : Yaml @@ -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 @@ -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 @@ -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;

6
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPadUntilHandler.cs

@ -11,6 +11,7 @@ public class YamlPlayoutPadUntilHandler(EnumeratorCache enumeratorCache) : YamlP @@ -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 @@ -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 @@ -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;

34
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutPreRollHandler.cs

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

1
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutRepeatHandler.cs

@ -13,6 +13,7 @@ public class YamlPlayoutRepeatHandler : IYamlPlayoutHandler @@ -13,6 +13,7 @@ public class YamlPlayoutRepeatHandler : IYamlPlayoutHandler
YamlPlayoutContext context,
YamlPlayoutInstruction instruction,
PlayoutBuildMode mode,
Func<string, Task> executeSequence,
ILogger<YamlPlayoutBuilder> logger,
CancellationToken cancellationToken)
{

1
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutShuffleSequenceHandler.cs

@ -11,6 +11,7 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler @@ -11,6 +11,7 @@ public class YamlPlayoutShuffleSequenceHandler : IYamlPlayoutHandler
YamlPlayoutContext context,
YamlPlayoutInstruction instruction,
PlayoutBuildMode mode,
Func<string, Task> executeSequence,
ILogger<YamlPlayoutBuilder> logger,
CancellationToken cancellationToken)
{

1
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutSkipItemsHandler.cs

@ -13,6 +13,7 @@ public class YamlPlayoutSkipItemsHandler(EnumeratorCache enumeratorCache) : IYam @@ -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)
{

1
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutSkipToItemHandler.cs

@ -13,6 +13,7 @@ public class YamlPlayoutSkipToItemHandler(EnumeratorCache enumeratorCache) : IYa @@ -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)
{

1
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWaitUntilHandler.cs

@ -11,6 +11,7 @@ public class YamlPlayoutWaitUntilHandler : IYamlPlayoutHandler @@ -11,6 +11,7 @@ public class YamlPlayoutWaitUntilHandler : IYamlPlayoutHandler
YamlPlayoutContext context,
YamlPlayoutInstruction instruction,
PlayoutBuildMode mode,
Func<string, Task> executeSequence,
ILogger<YamlPlayoutBuilder> logger,
CancellationToken cancellationToken)
{

1
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutWatermarkHandler.cs

@ -13,6 +13,7 @@ public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) : @@ -13,6 +13,7 @@ public class YamlPlayoutWatermarkHandler(IChannelRepository channelRepository) :
YamlPlayoutContext context,
YamlPlayoutInstruction instruction,
PlayoutBuildMode mode,
Func<string, Task> executeSequence,
ILogger<YamlPlayoutBuilder> logger,
CancellationToken cancellationToken)
{

11
ErsatzTV.Core/Scheduling/YamlScheduling/Models/YamlPreRollInstruction.cs

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

81
ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs

@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Scheduling; @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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) },

93
ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutContext.cs

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

6148
ErsatzTV.Infrastructure.MySql/Migrations/20250801025332_Add_PlayoutAnchorContext.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20250801025332_Add_PlayoutAnchorContext.cs

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

3
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -4510,6 +4510,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -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)");

5985
ErsatzTV.Infrastructure.Sqlite/Migrations/20250801025302_Add_PlayoutAnchorContext.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20250801025302_Add_PlayoutAnchorContext.cs

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

3
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -4347,6 +4347,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -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");

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -28,6 +28,7 @@ @@ -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" />

131
ErsatzTV.Infrastructure/Scheduling/YamlScheduleValidator.cs

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

1
ErsatzTV/ErsatzTV.csproj

@ -81,6 +81,7 @@ @@ -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>

146
ErsatzTV/Resources/yaml-playout.schema.json

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

1
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -24,6 +24,7 @@ public class ResourceExtractorService : BackgroundService @@ -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);

1
ErsatzTV/Startup.cs

@ -730,6 +730,7 @@ public class Startup @@ -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>();

Loading…
Cancel
Save