Browse Source

fix: improve flexible start time handling near midnight

pull/2967/head
Jason Dove 1 day ago
parent
commit
c4791527dd
No known key found for this signature in database
  1. 9
      CHANGELOG.md
  2. 126
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs
  3. 122
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs
  4. 26
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

9
CHANGELOG.md

@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Changed
- Change rule for how `Flexible` fixed start times work in classic schedules
- Previously, flexible waited only for start times later in the same *calendar day*
- This caused undesired behavior near midnight
- A flexible start time of 23:55 when the current playout is at 00:05 would wait nearly a full day
- A flexible start time of 00:30 when the current playout is at 23:50 would start immediately instead of waiting 40 minutes
- Now, flexible will only wait when the desired start time is <= 12 hours after the current time, otherwise it will start immediately
- `Strict` behavior is unchanged
### Fixed ### Fixed
- Fix regression from `v26.2.0` that caused channel logo watermarks to be ignored when the logo is a url - Fix regression from `v26.2.0` that caused channel logo watermarks to be ignored when the logo is a url
- This affected external logo urls and generated channel logos - This affected external logo urls and generated channel logos

126
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs

@ -36,10 +36,117 @@ public class GetStartTimeAfterTests
[Test] [Test]
public void Should_Return_Current_Time_With_Flexible_Fixed_Start() public void Should_Return_Current_Time_With_Flexible_Fixed_Start()
{ {
// 12:05 am // 12:05 am, five minutes before the current time
DateTimeOffset currentTime = Local(2025, 8, 29, 0, 10, 0);
DateTimeOffset result = FlexibleStartTimeAfter(TimeSpan.FromMinutes(5), currentTime);
result.ShouldBe(currentTime);
}
[Test]
public void Flexible_Should_Wait_For_Midnight_Start_Time_Just_Before_Midnight()
{
DateTimeOffset result = FlexibleStartTimeAfter(TimeSpan.Zero, Local(2026, 7, 30, 23, 54, 58));
result.ShouldBe(Local(2026, 7, 31, 0, 0, 0));
}
[Test]
public void Flexible_Should_Wait_For_Early_Morning_Start_Time_Just_Before_Midnight()
{
DateTimeOffset result = FlexibleStartTimeAfter(
TimeSpan.FromMinutes(30),
Local(2026, 7, 30, 23, 50, 0));
result.ShouldBe(Local(2026, 7, 31, 0, 30, 0));
}
[Test]
public void Flexible_Should_Wait_Hours_For_A_Midnight_Start_Time()
{
// the nearest occurrence of midnight is an hour and a half ahead, not the one 22:30 behind
DateTimeOffset result = FlexibleStartTimeAfter(TimeSpan.Zero, Local(2026, 7, 30, 22, 30, 0));
result.ShouldBe(Local(2026, 7, 31, 0, 0, 0));
}
[Test]
public void Flexible_Should_Not_Wait_A_Day_For_Midnight_Start_Time_After_Midnight()
{
DateTimeOffset currentTime = Local(2026, 7, 31, 0, 20, 1);
DateTimeOffset result = FlexibleStartTimeAfter(TimeSpan.Zero, currentTime);
result.ShouldBe(currentTime);
}
[Test]
public void Flexible_Should_Not_Wait_A_Day_For_Start_Time_That_Just_Passed()
{
DateTimeOffset currentTime = Local(2026, 7, 31, 6, 10, 0);
DateTimeOffset result = FlexibleStartTimeAfter(TimeSpan.FromHours(6), currentTime);
result.ShouldBe(currentTime);
}
[Test]
public void Flexible_Should_Wait_For_Later_Start_Time_On_The_Same_Day()
{
DateTimeOffset result = FlexibleStartTimeAfter(TimeSpan.FromHours(18), Local(2026, 7, 31, 12, 0, 0));
result.ShouldBe(Local(2026, 7, 31, 18, 0, 0));
}
[Test]
public void Flexible_Should_Not_Wait_A_Day_For_A_Late_Start_Time_Just_After_Midnight()
{
// the start time we missed two minutes ago is yesterday's, so this waits nearly a full day
// to reach the next one; flexible starts immediately instead, even though the start time is
// later on the same calendar day
DateTimeOffset currentTime = Local(2026, 7, 31, 0, 1, 0);
DateTimeOffset result = FlexibleStartTimeAfter(new TimeSpan(23, 59, 0), currentTime);
result.ShouldBe(currentTime);
}
[Test]
public void Flexible_Should_Wait_For_Midnight_Start_Time_On_Every_Day_Of_The_Year()
{
// waiting ten minutes for midnight has to land on midnight on every day, including the day
// of a DST change, when tomorrow's offset differs from today's
var date = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
var failures = new List<string>();
while (date.Year == 2026)
{
DateTime midnight = date.AddDays(1);
// a zone that changes offset at midnight has no 12:00 am to wait for
if (!TimeZoneInfo.Local.IsInvalidTime(midnight) && !TimeZoneInfo.Local.IsAmbiguousTime(midnight))
{
DateTimeOffset currentTime = Local(date.Year, date.Month, date.Day, 23, 50, 0);
DateTimeOffset result = FlexibleStartTimeAfter(TimeSpan.Zero, currentTime);
if (result.DateTime != midnight)
{
failures.Add($"{currentTime:yyyy-MM-dd HH:mm zzz} -> {result:yyyy-MM-dd HH:mm zzz}");
}
}
date = date.AddDays(1);
}
failures.ShouldBeEmpty($"[tz {TimeZoneInfo.Local.Id}] did not wait for midnight");
}
private static DateTimeOffset FlexibleStartTimeAfter(TimeSpan itemStartTime, DateTimeOffset currentTime)
{
var scheduleItem = new ProgramScheduleItemOne var scheduleItem = new ProgramScheduleItemOne
{ {
StartTime = TimeSpan.FromMinutes(5), StartTime = itemStartTime,
FixedStartTimeBehavior = null, FixedStartTimeBehavior = null,
ProgramSchedule = new ProgramSchedule ProgramSchedule = new ProgramSchedule
{ {
@ -55,11 +162,18 @@ public class GetStartTimeAfterTests
false, false,
false, false,
0, 0,
DateTimeOffset.Parse("2025-08-29T00:10:00-05:00")); currentTime);
DateTimeOffset result = return PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(
PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(state, scheduleItem, Option<ILogger>.None); state,
scheduleItem,
Option<ILogger>.None);
}
result.ShouldBe(DateTimeOffset.Parse("2025-08-29T00:10:00-05:00")); // these tests describe local wall clock behavior, so they have to be built from the local time zone
private static DateTimeOffset Local(int year, int month, int day, int hour, int minute, int second)
{
var unspecified = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified);
return new DateTimeOffset(unspecified, TimeZoneInfo.Local.GetUtcOffset(unspecified));
} }
} }

122
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs

@ -2134,4 +2134,126 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
playout.Anchor.NextStartOffset.ShouldBe(start + TimeSpan.FromHours(48)); playout.Anchor.NextStartOffset.ShouldBe(start + TimeSpan.FromHours(48));
} }
[Test]
public async Task OneContent_Should_Wait_For_Flexible_Midnight_Start_Time()
{
var lateCollection = new Collection
{
Id = 1,
Name = "Late Items",
MediaItems = [TestMovie(1, TimeSpan.FromMinutes(25), new DateTime(2020, 1, 1))]
};
var midnightCollection = new Collection
{
Id = 2,
Name = "Midnight Items",
MediaItems = [TestMovie(2, TimeSpan.FromMinutes(25), new DateTime(2020, 1, 2))]
};
var earlyCollection = new Collection
{
Id = 3,
Name = "Early Items",
MediaItems = [TestMovie(3, TimeSpan.FromMinutes(25), new DateTime(2020, 1, 3))]
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(lateCollection.Id, lateCollection.MediaItems.ToList()),
(midnightCollection.Id, midnightCollection.MediaItems.ToList()),
(earlyCollection.Id, earlyCollection.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
OneAt(1, lateCollection, TimeSpan.FromHours(23.5)),
OneAt(2, midnightCollection, TimeSpan.Zero),
OneAt(3, earlyCollection, TimeSpan.FromMinutes(30))
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule { Items = items },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = [],
Items = [],
ProgramScheduleAlternates = [],
FillGroupIndices = []
};
var referenceData = new PlayoutReferenceData(
playout.Channel,
Option<Deco>.None,
[],
[],
playout.ProgramSchedule,
[],
[],
TimeSpan.Zero);
IConfigElementRepository configRepo = Substitute.For<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder(
configRepo,
fakeRepository,
televisionRepo,
artistRepo,
factory,
new MockFileSystem(),
rerunHelper,
Logger);
DateTimeOffset start = HoursAfterMidnight(23);
DateTimeOffset finish = start + TimeSpan.FromHours(2.5);
Either<BaseError, PlayoutBuildResult> buildResult = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
start,
finish,
CancellationToken);
buildResult.IsRight.ShouldBeTrue();
foreach (var result in buildResult.RightToSeq())
{
result.AddedItems.Count.ShouldBe(5);
result.AddedItems[0].MediaItemId.ShouldBe(1);
result.AddedItems[0].StartOffset.ShouldBe(start + TimeSpan.FromMinutes(30));
// the 11:30 pm item finishes at 11:55 pm, but the midnight item must still wait for midnight
result.AddedItems[1].MediaItemId.ShouldBe(2);
result.AddedItems[1].StartOffset.ShouldBe(start + TimeSpan.FromHours(1));
result.AddedItems[2].MediaItemId.ShouldBe(3);
result.AddedItems[2].StartOffset.ShouldBe(start + TimeSpan.FromHours(1.5));
// wrapping back to the 11:30 pm item means the nearest 11:30 pm is almost a day behind, so
// these start immediately rather than waiting out the rest of the day
result.AddedItems[3].MediaItemId.ShouldBe(1);
result.AddedItems[3].StartOffset.ShouldBe(result.AddedItems[2].FinishOffset);
result.AddedItems[4].MediaItemId.ShouldBe(2);
result.AddedItems[4].StartOffset.ShouldBe(result.AddedItems[3].FinishOffset);
}
}
private static ProgramScheduleItem OneAt(int id, Collection collection, TimeSpan startTime) =>
new ProgramScheduleItemOne
{
Id = id,
Index = id,
Collection = collection,
CollectionId = collection.Id,
StartTime = startTime,
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible,
PlaybackOrder = PlaybackOrder.Chronological
};
} }

26
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -15,6 +15,11 @@ namespace ErsatzTV.Core.Scheduling;
public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutModeScheduler<T> public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutModeScheduler<T>
where T : ProgramScheduleItem where T : ProgramScheduleItem
{ {
// half a day, so that a flexible item always moves to whichever occurrence of its start time
// is nearest; waiting longer than this would mean the start time is one we already missed
// ReSharper disable once StaticMemberInGenericType
private static readonly TimeSpan MaximumFlexibleWait = TimeSpan.FromHours(12);
// ReSharper disable once StaticMemberInGenericType // ReSharper disable once StaticMemberInGenericType
private static readonly JsonSerializerOptions Options = new() private static readonly JsonSerializerOptions Options = new()
{ {
@ -82,6 +87,13 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
DateTimeOffset result = new DateTimeOffset(withStartTime, TimeZoneInfo.Local.GetUtcOffset(withStartTime)); DateTimeOffset result = new DateTimeOffset(withStartTime, TimeZoneInfo.Local.GetUtcOffset(withStartTime));
// tomorrow's occurrence of the same start time; the offset has to be re-derived rather than
// carried over from result, or a DST change between the two shifts the wall clock by the delta
DateTime tomorrowWithStartTime = withStartTime.AddDays(1);
DateTimeOffset tomorrowResult = new DateTimeOffset(
tomorrowWithStartTime,
TimeZoneInfo.Local.GetUtcOffset(tomorrowWithStartTime));
// Serilog.Log.Logger.Debug( // Serilog.Log.Logger.Debug(
// "StartTimeOfDay: {StartTimeOfDay} Item Start Time: {ItemStartTime}", // "StartTimeOfDay: {StartTimeOfDay} Item Start Time: {ItemStartTime}",
// startTime.TimeOfDay.TotalMilliseconds, // startTime.TimeOfDay.TotalMilliseconds,
@ -101,12 +113,18 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
// we should use the next day's time to allow the flood to continue. // we should use the next day's time to allow the flood to continue.
if (isPeek && state.InFlood && startTime > result) if (isPeek && state.InFlood && startTime > result)
{ {
startTime = result.AddDays(1); startTime = tomorrowResult;
} }
// otherwise, only wait for times on the same day else
else if (result.Day == startTime.Day && result.TimeOfDay > startTime.TimeOfDay) {
// wait for the next occurrence of the start time, unless it is far enough away
// that the occurrence we just missed is the nearer one - waiting for that would
// add most of a day of unscheduled time, which is what flexible exists to avoid
DateTimeOffset nextOccurrence = result >= startTime ? result : tomorrowResult;
if (nextOccurrence - startTime <= MaximumFlexibleWait)
{ {
startTime = result; startTime = nextOccurrence;
}
} }
break; break;

Loading…
Cancel
Save