Browse Source

fix: reset out point when fallback replaces content that did not fit

The fallback branch reuses the playout item built for the content item
that was just rejected for being too long. It swaps in the fallback's
media item id and pulls the finish time back to the target, but leaves
the out point at the rejected item's duration, so the item's in and out
points describe a different media item than the one it now points at.
The trim branch directly above already handles this correctly.

The error is the difference between the two durations and is unbounded:
a thirty minute item rejected from a ten minute gap leaves the fallback
carrying a thirty minute out point.

PlayoutItemConverter exports the out point whenever it is shorter than
the media item's own duration, so the wrong value also reaches the next
engine's playout json, where it decides how much of the fallback plays.

Adds regression coverage for the fallback path, which had none.
pull/2987/head
Ministorm3 1 month ago
parent
commit
3069790119
  1. 124
      ErsatzTV.Core.Tests/Scheduling/YamlPlayoutFallbackTests.cs
  2. 7
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutDurationHandler.cs

124
ErsatzTV.Core.Tests/Scheduling/YamlPlayoutFallbackTests.cs

@ -0,0 +1,124 @@ @@ -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 = [] }]
};
/// <summary>
/// Reaches the protected scheduling routine with hand-built enumerators, so
/// the fallback path can be exercised without a content database.
/// </summary>
private class TestableDurationHandler : YamlPlayoutDurationHandler
{
private TestableDurationHandler() : base(null)
{
}
public static Task<DateTimeOffset> 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<IMediaCollectionEnumerator>.Some(fallbackEnumerator),
_ => Task.CompletedTask,
NullLogger<SequentialPlayoutBuilder>.Instance);
}
private class TestEnumerator(MediaItem mediaItem) : IMediaCollectionEnumerator
{
public string SchedulingContextName => "test";
public CollectionEnumeratorState State { get; } = new() { Index = 0, Seed = 0 };
public Option<MediaItem> Current => mediaItem;
public Option<bool> CurrentIncludeInProgramGuide => Option<bool>.None;
public int Count => 1;
public Option<TimeSpan> MinimumDuration => mediaItem.GetDurationForPlayout();
public void ResetState(CollectionEnumeratorState state) { }
public void MoveNext(Option<DateTimeOffset> scheduledAt) { }
}
}

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

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

Loading…
Cancel
Save