diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj
index 1df6568f..2d24122d 100644
--- a/ErsatzTV.Core/ErsatzTV.Core.csproj
+++ b/ErsatzTV.Core/ErsatzTV.Core.csproj
@@ -27,6 +27,7 @@
+
diff --git a/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateDurationItem.cs b/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateDurationItem.cs
new file mode 100644
index 00000000..ca728247
--- /dev/null
+++ b/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateDurationItem.cs
@@ -0,0 +1,15 @@
+using YamlDotNet.Serialization;
+
+namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
+
+public class PlayoutTemplateDurationItem : PlayoutTemplateItem
+{
+ public string Duration { get; set; }
+
+ public bool Trim { get; set; }
+
+ public string Fallback { get; set; }
+
+ [YamlMember(Alias = "discard_attempts", ApplyNamingConventions = false)]
+ public int DiscardAttempts { get; set; }
+}
diff --git a/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerDuration.cs b/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerDuration.cs
new file mode 100644
index 00000000..eced7c73
--- /dev/null
+++ b/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerDuration.cs
@@ -0,0 +1,119 @@
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Domain.Filler;
+using ErsatzTV.Core.Interfaces.Scheduling;
+using TimeSpanParserUtil;
+
+namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
+
+public class PlayoutTemplateSchedulerDuration : PlayoutTemplateScheduler
+{
+ public static DateTimeOffset Schedule(
+ Playout playout,
+ DateTimeOffset currentTime,
+ PlayoutTemplateDurationItem duration,
+ IMediaCollectionEnumerator enumerator,
+ Option fallbackEnumerator)
+ {
+ // TODO: move to up-front validation somewhere
+ if (!TimeSpanParser.TryParse(duration.Duration, out TimeSpan timeSpan))
+ {
+ return currentTime;
+ }
+
+ DateTimeOffset targetTime = currentTime.Add(timeSpan);
+
+ return Schedule(
+ playout,
+ currentTime,
+ targetTime,
+ duration.DiscardAttempts,
+ duration.Trim,
+ enumerator,
+ fallbackEnumerator);
+ }
+
+ protected static DateTimeOffset Schedule(
+ Playout playout,
+ DateTimeOffset currentTime,
+ DateTimeOffset targetTime,
+ int discardAttempts,
+ bool trim,
+ IMediaCollectionEnumerator enumerator,
+ Option fallbackEnumerator)
+ {
+ bool done = false;
+ TimeSpan remainingToFill = targetTime - currentTime;
+ while (!done && enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero)
+ {
+ foreach (MediaItem mediaItem in enumerator.Current)
+ {
+ TimeSpan itemDuration = DurationForMediaItem(mediaItem);
+
+ var playoutItem = new PlayoutItem
+ {
+ MediaItemId = mediaItem.Id,
+ Start = currentTime.UtcDateTime,
+ Finish = currentTime.UtcDateTime + itemDuration,
+ InPoint = TimeSpan.Zero,
+ OutPoint = itemDuration,
+ //GuideGroup = playoutBuilderState.NextGuideGroup,
+ //FillerKind = fillerKind,
+ //DisableWatermarks = !allowWatermarks
+ };
+
+ if (remainingToFill - itemDuration >= TimeSpan.Zero)
+ {
+ remainingToFill -= itemDuration;
+ currentTime += itemDuration;
+
+ playout.Items.Add(playoutItem);
+ enumerator.MoveNext();
+ }
+ else if (discardAttempts > 0)
+ {
+ // item won't fit; try the next one
+ discardAttempts--;
+ enumerator.MoveNext();
+ }
+ else if (trim)
+ {
+ // trim item to exactly fit
+ remainingToFill = TimeSpan.Zero;
+ currentTime = targetTime;
+
+ playoutItem.Finish = targetTime.UtcDateTime;
+ playoutItem.OutPoint = playoutItem.Finish - playoutItem.Start;
+
+ playout.Items.Add(playoutItem);
+ enumerator.MoveNext();
+ }
+ else if (fallbackEnumerator.IsSome)
+ {
+ foreach (IMediaCollectionEnumerator fallback in fallbackEnumerator)
+ {
+ remainingToFill = TimeSpan.Zero;
+ done = true;
+
+ // replace with fallback content
+ foreach (MediaItem fallbackItem in fallback.Current)
+ {
+ playoutItem.MediaItemId = fallbackItem.Id;
+ playoutItem.Finish = targetTime.UtcDateTime;
+ playoutItem.FillerKind = FillerKind.Fallback;
+
+ playout.Items.Add(playoutItem);
+ fallback.MoveNext();
+ }
+ }
+ }
+ else
+ {
+ // item won't fit; we're done
+ done = true;
+ }
+ }
+ }
+
+ return targetTime;
+ }
+}
diff --git a/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerPadToNext.cs b/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerPadToNext.cs
index 7aef99d1..a8f16225 100644
--- a/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerPadToNext.cs
+++ b/ErsatzTV.Core/Scheduling/TemplateScheduling/PlayoutTemplateSchedulerPadToNext.cs
@@ -1,10 +1,9 @@
using ErsatzTV.Core.Domain;
-using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling.TemplateScheduling;
-public class PlayoutTemplateSchedulerPadToNext : PlayoutTemplateScheduler
+public class PlayoutTemplateSchedulerPadToNext : PlayoutTemplateSchedulerDuration
{
public static DateTimeOffset Schedule(
Playout playout,
@@ -33,80 +32,13 @@ public class PlayoutTemplateSchedulerPadToNext : PlayoutTemplateScheduler
if (targetTime <= currentTime)
targetTime = targetTime.AddMinutes(padToNext.PadToNext);
- int discardAttempts = padToNext.DiscardAttempts;
- bool done = false;
- TimeSpan remainingToFill = targetTime - currentTime;
- while (!done && enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero)
- {
- foreach (MediaItem mediaItem in enumerator.Current)
- {
- TimeSpan itemDuration = DurationForMediaItem(mediaItem);
-
- var playoutItem = new PlayoutItem
- {
- MediaItemId = mediaItem.Id,
- Start = currentTime.UtcDateTime,
- Finish = currentTime.UtcDateTime + itemDuration,
- InPoint = TimeSpan.Zero,
- OutPoint = itemDuration,
- //GuideGroup = playoutBuilderState.NextGuideGroup,
- //FillerKind = fillerKind,
- //DisableWatermarks = !allowWatermarks
- };
-
- if (remainingToFill - itemDuration >= TimeSpan.Zero)
- {
- remainingToFill -= itemDuration;
- currentTime += itemDuration;
-
- playout.Items.Add(playoutItem);
- enumerator.MoveNext();
- }
- else if (discardAttempts > 0)
- {
- // item won't fit; try the next one
- discardAttempts--;
- enumerator.MoveNext();
- }
- else if (padToNext.Trim)
- {
- // trim item to exactly fit
- remainingToFill = TimeSpan.Zero;
- currentTime = targetTime;
-
- playoutItem.Finish = targetTime.UtcDateTime;
- playoutItem.OutPoint = playoutItem.Finish - playoutItem.Start;
-
- playout.Items.Add(playoutItem);
- enumerator.MoveNext();
- }
- else if (fallbackEnumerator.IsSome)
- {
- foreach (IMediaCollectionEnumerator fallback in fallbackEnumerator)
- {
- remainingToFill = TimeSpan.Zero;
- done = true;
-
- // replace with fallback content
- foreach (MediaItem fallbackItem in fallback.Current)
- {
- playoutItem.MediaItemId = fallbackItem.Id;
- playoutItem.Finish = targetTime.UtcDateTime;
- playoutItem.FillerKind = FillerKind.Fallback;
-
- playout.Items.Add(playoutItem);
- fallback.MoveNext();
- }
- }
- }
- else
- {
- // item won't fit; we're done
- done = true;
- }
- }
- }
-
- return targetTime;
+ return Schedule(
+ playout,
+ currentTime,
+ targetTime,
+ padToNext.DiscardAttempts,
+ padToNext.Trim,
+ enumerator,
+ fallbackEnumerator);
}
}
diff --git a/ErsatzTV.Core/Scheduling/TemplateScheduling/TemplatePlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/TemplateScheduling/TemplatePlayoutBuilder.cs
index 2b5d51a1..a0fb69fe 100644
--- a/ErsatzTV.Core/Scheduling/TemplateScheduling/TemplatePlayoutBuilder.cs
+++ b/ErsatzTV.Core/Scheduling/TemplateScheduling/TemplatePlayoutBuilder.cs
@@ -44,6 +44,7 @@ public class TemplatePlayoutBuilder(
// load content and content enumerators on demand
Dictionary enumerators = new();
+ System.Collections.Generic.HashSet missingContentKeys = [];
int itemsAfterRepeat = playout.Items.Count;
var index = 0;
@@ -80,31 +81,49 @@ public class TemplatePlayoutBuilder(
if (maybeEnumerator.IsNone)
{
- logger.LogWarning("Unable to locate content with key {Key}", playoutItem.Content);
- continue;
+ if (!missingContentKeys.Contains(playoutItem.Content))
+ {
+ logger.LogWarning("Unable to locate content with key {Key}", playoutItem.Content);
+ missingContentKeys.Add(playoutItem.Content);
+ }
}
- IMediaCollectionEnumerator enumerator = maybeEnumerator.ValueUnsafe();
-
- switch (playoutItem)
+ foreach (IMediaCollectionEnumerator enumerator in maybeEnumerator)
{
- case PlayoutTemplateCountItem count:
- currentTime = PlayoutTemplateSchedulerCount.Schedule(playout, currentTime, count, enumerator);
- break;
- case PlayoutTemplatePadToNextItem padToNext:
- Option fallbackEnumerator = await GetCachedEnumeratorForContent(
- playout,
- playoutTemplate,
- enumerators,
- padToNext.Fallback,
- cancellationToken);
- currentTime = PlayoutTemplateSchedulerPadToNext.Schedule(
- playout,
- currentTime,
- padToNext,
- enumerator,
- fallbackEnumerator);
- break;
+ switch (playoutItem)
+ {
+ case PlayoutTemplateCountItem count:
+ currentTime = PlayoutTemplateSchedulerCount.Schedule(playout, currentTime, count, enumerator);
+ break;
+ case PlayoutTemplateDurationItem duration:
+ Option durationFallbackEnumerator = await GetCachedEnumeratorForContent(
+ playout,
+ playoutTemplate,
+ enumerators,
+ duration.Fallback,
+ cancellationToken);
+ currentTime = PlayoutTemplateSchedulerDuration.Schedule(
+ playout,
+ currentTime,
+ duration,
+ enumerator,
+ durationFallbackEnumerator);
+ break;
+ case PlayoutTemplatePadToNextItem padToNext:
+ Option fallbackEnumerator = await GetCachedEnumeratorForContent(
+ playout,
+ playoutTemplate,
+ enumerators,
+ padToNext.Fallback,
+ cancellationToken);
+ currentTime = PlayoutTemplateSchedulerPadToNext.Schedule(
+ playout,
+ currentTime,
+ padToNext,
+ enumerator,
+ fallbackEnumerator);
+ break;
+ }
}
index++;
@@ -190,6 +209,7 @@ public class TemplatePlayoutBuilder(
var keyMappings = new Dictionary
{
{ "count", typeof(PlayoutTemplateCountItem) },
+ { "duration", typeof(PlayoutTemplateDurationItem) },
{ "pad_to_next", typeof(PlayoutTemplatePadToNextItem) },
{ "repeat", typeof(PlayoutTemplateRepeatItem) }
};