From a7ecc5f96a4db2e7c5d23c7e9c6cec3ae1e2b46c Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:43:34 -0500 Subject: [PATCH] fix: improve flexible start time handling near midnight (#2967) --- CHANGELOG.md | 9 ++ .../GetStartTimeAfterTests.cs | 126 +++++++++++++++++- .../ClassicScheduling/NewPlayoutTests.cs | 122 +++++++++++++++++ .../Scheduling/PlayoutModeSchedulerBase.cs | 26 +++- 4 files changed, 273 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cba062908..3a5d04894 100644 --- a/CHANGELOG.md +++ b/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/). ## [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 - 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 diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs index 9f2031c78..366560a7c 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs @@ -36,10 +36,117 @@ public class GetStartTimeAfterTests [Test] 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(); + + 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 { - StartTime = TimeSpan.FromMinutes(5), + StartTime = itemStartTime, FixedStartTimeBehavior = null, ProgramSchedule = new ProgramSchedule { @@ -55,11 +162,18 @@ public class GetStartTimeAfterTests false, false, 0, - DateTimeOffset.Parse("2025-08-29T00:10:00-05:00")); + currentTime); - DateTimeOffset result = - PlayoutModeSchedulerBase.GetStartTimeAfter(state, scheduleItem, Option.None); + return PlayoutModeSchedulerBase.GetStartTimeAfter( + state, + scheduleItem, + Option.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)); } } diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs index 78b553382..e01fbf6b1 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs @@ -2134,4 +2134,126 @@ public class NewPlayoutTests : PlayoutBuilderTestBase 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 + { + 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.None, + [], + [], + playout.ProgramSchedule, + [], + [], + TimeSpan.Zero); + + IConfigElementRepository configRepo = Substitute.For(); + var televisionRepo = new FakeTelevisionRepository(); + IArtistRepository artistRepo = Substitute.For(); + IMultiEpisodeShuffleCollectionEnumeratorFactory factory = + Substitute.For(); + IRerunHelper rerunHelper = Substitute.For(); + 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 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 + }; } diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs index c4e41e748..394fa71ad 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs @@ -15,6 +15,11 @@ namespace ErsatzTV.Core.Scheduling; public abstract class PlayoutModeSchedulerBase(ILogger logger) : IPlayoutModeScheduler 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 private static readonly JsonSerializerOptions Options = new() { @@ -82,6 +87,13 @@ public abstract class PlayoutModeSchedulerBase(ILogger logger) : IPlayoutMode 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( // "StartTimeOfDay: {StartTimeOfDay} Item Start Time: {ItemStartTime}", // startTime.TimeOfDay.TotalMilliseconds, @@ -101,12 +113,18 @@ public abstract class PlayoutModeSchedulerBase(ILogger logger) : IPlayoutMode // we should use the next day's time to allow the flood to continue. if (isPeek && state.InFlood && startTime > result) { - startTime = result.AddDays(1); + startTime = tomorrowResult; } - // otherwise, only wait for times on the same day - else if (result.Day == startTime.Day && result.TimeOfDay > startTime.TimeOfDay) + else { - startTime = result; + // 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 = nextOccurrence; + } } break;