diff --git a/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutFallbackTests.cs b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutFallbackTests.cs new file mode 100644 index 000000000..3df4fdd54 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/YamlPlayoutFallbackTests.cs @@ -0,0 +1,124 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Scheduling.YamlScheduling; +using ErsatzTV.Core.Scheduling.YamlScheduling.Handlers; +using ErsatzTV.Core.Scheduling.YamlScheduling.Models; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Core.Tests.Scheduling; + +[TestFixture] +public class YamlPlayoutFallbackTests +{ + private static readonly DateTimeOffset Start = new(2025, 4, 15, 12, 0, 0, TimeSpan.FromHours(-5)); + + [Test] + public async Task Fallback_Should_Not_Keep_Out_Point_Of_Content_That_Did_Not_Fit() + { + // the gap is ten minutes; the next content item runs thirty, so it is + // rejected and the fallback takes its place + var content = TestMovie(1, TimeSpan.FromMinutes(30)); + var fallback = TestMovie(2, TimeSpan.FromMinutes(60)); + + var context = new YamlPlayoutContext(new Playout { Id = 1 }, new YamlPlayoutDefinition(), 1) + { + CurrentTime = Start + }; + + await TestableDurationHandler.Run( + context, + targetTime: Start + TimeSpan.FromMinutes(10), + new TestEnumerator(content), + new TestEnumerator(fallback)); + + context.AddedItems.Count.ShouldBe(1); + + PlayoutItem item = context.AddedItems[0]; + item.FillerKind.ShouldBe(FillerKind.Fallback); + item.MediaItemId.ShouldBe(fallback.Id); + + // the out point belongs to the fallback filling a ten minute gap, not + // to the thirty minute item it replaced + item.OutPoint.ShouldBe(TimeSpan.FromMinutes(10)); + (item.Finish - item.Start).ShouldBe(item.OutPoint - item.InPoint); + } + + [Test] + public async Task Fallback_Out_Point_Should_Match_Gap_When_Shorter_Than_Fallback() + { + var content = TestMovie(1, TimeSpan.FromMinutes(30)); + var fallback = TestMovie(2, TimeSpan.FromMinutes(45)); + + var context = new YamlPlayoutContext(new Playout { Id = 1 }, new YamlPlayoutDefinition(), 1) + { + CurrentTime = Start + }; + + await TestableDurationHandler.Run( + context, + targetTime: Start + TimeSpan.FromSeconds(90), + new TestEnumerator(content), + new TestEnumerator(fallback)); + + context.AddedItems.Count.ShouldBe(1); + context.AddedItems[0].OutPoint.ShouldBe(TimeSpan.FromSeconds(90)); + } + + private static Movie TestMovie(int id, TimeSpan duration) => + new() + { + Id = id, + MovieMetadata = [new MovieMetadata { ReleaseDate = new DateTime(2000, 1, 1) }], + MediaVersions = [new MediaVersion { Duration = duration, Chapters = [] }] + }; + + /// + /// Reaches the protected scheduling routine with hand-built enumerators, so + /// the fallback path can be exercised without a content database. + /// + private class TestableDurationHandler : YamlPlayoutDurationHandler + { + private TestableDurationHandler() : base(null) + { + } + + public static Task Run( + YamlPlayoutContext context, + DateTimeOffset targetTime, + IMediaCollectionEnumerator enumerator, + IMediaCollectionEnumerator fallbackEnumerator) => + Schedule( + context, + contentKey: "content", + fallbackContentKey: "fallback", + targetTime, + stopBeforeEnd: true, + discardAttempts: 0, + trim: false, + offlineTail: false, + FillerKind.None, + customTitle: null, + disableWatermarks: false, + enumerator, + Option.Some(fallbackEnumerator), + _ => Task.CompletedTask, + NullLogger.Instance); + } + + private class TestEnumerator(MediaItem mediaItem) : IMediaCollectionEnumerator + { + public string SchedulingContextName => "test"; + public CollectionEnumeratorState State { get; } = new() { Index = 0, Seed = 0 }; + public Option Current => mediaItem; + public Option CurrentIncludeInProgramGuide => Option.None; + public int Count => 1; + public Option MinimumDuration => mediaItem.GetDurationForPlayout(); + public void ResetState(CollectionEnumeratorState state) { } + public void MoveNext(Option scheduledAt) { } + } +} diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs index 4dba4a0f3..8b8ae3a2f 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs @@ -221,6 +221,13 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP { playoutItem.MediaItemId = fallbackItem.Id; playoutItem.Finish = targetTime.UtcDateTime; + + // this item was built for the content that did not fit, so its + // out point is that item's duration and has nothing to do with + // the fallback taking its place; leaving it makes the fallback + // claim to run longer than the gap it fills + playoutItem.OutPoint = playoutItem.Finish - playoutItem.Start; + playoutItem.FillerKind = FillerKind.Fallback; context.AddedItems.Add(playoutItem);