From 8a1cf72209010e1fdf78d5852adc3b82c6f84211 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:48:39 -0500 Subject: [PATCH] more alternate schedule fixes (#2354) * always start with the first schedule item * reset program schedule items to zero-based index on save * log offline gaps from strict start times --- CHANGELOG.md | 2 + .../ReplaceProgramScheduleItemsHandler.cs | 9 +++- .../GetStartTimeAfterTests.cs | 34 ++++++++++++- .../PlayoutModeSchedulerBaseTests.cs | 6 ++- ErsatzTV.Core/ErsatzTV.Core.csproj | 1 + ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 51 +++++++++++++++++-- .../Scheduling/PlayoutModeSchedulerBase.cs | 23 ++++++++- .../PlayoutModeSchedulerDuration.cs | 2 +- .../Scheduling/PlayoutModeSchedulerFlood.cs | 4 +- .../PlayoutModeSchedulerMultiple.cs | 4 +- .../Scheduling/PlayoutModeSchedulerOne.cs | 5 +- 11 files changed, 126 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa14a1af..c6dc81a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix error when changing default (lowest priority) alternate schedule - Fix remote library editing, tv shows, artists with MySql/MariaDB - Classic schedules: fix alternate schedule transitions (some edge cases would cause days to be skipped completely) +- Classic schedules: always start new alternate schedules with the first schedule item +- Classic Schedules: log offline gaps longer than 1 hour due to strict fixed start times ### Changed - Rename some schedule and playout terms for clarity diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs index 97540c946..7b7537b67 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs @@ -39,7 +39,14 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase CancellationToken cancellationToken) { dbContext.RemoveRange(programSchedule.Items); - programSchedule.Items = request.Items.Map(i => BuildItem(programSchedule, i.Index, i)).ToList(); + + // reset index starting with zero + programSchedule.Items = []; + var orderedItems = request.Items.OrderBy(i => i.Index).ToList(); + for (var i = 0; i < orderedItems.Count; i++) + { + programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i])); + } await dbContext.SaveChangesAsync(cancellationToken); diff --git a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs index a6f0ce815..9f2031c78 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Scheduling; +using Microsoft.Extensions.Logging; using NUnit.Framework; using Shouldly; @@ -26,8 +27,39 @@ public class GetStartTimeAfterTests 0, DateTimeOffset.Parse("2025-11-02T00:00:00-05:00")); - DateTimeOffset result = PlayoutModeSchedulerBase.GetStartTimeAfter(state, scheduleItem); + DateTimeOffset result = + PlayoutModeSchedulerBase.GetStartTimeAfter(state, scheduleItem, Option.None); result.ShouldBe(DateTimeOffset.Parse("2025-11-02T02:00:00-06:00")); } + + [Test] + public void Should_Return_Current_Time_With_Flexible_Fixed_Start() + { + // 12:05 am + var scheduleItem = new ProgramScheduleItemOne + { + StartTime = TimeSpan.FromMinutes(5), + FixedStartTimeBehavior = null, + ProgramSchedule = new ProgramSchedule + { + FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible + } + }; + + var state = new PlayoutBuilderState( + 0, + null, + Option.None, + Option.None, + false, + false, + 0, + DateTimeOffset.Parse("2025-08-29T00:10:00-05:00")); + + DateTimeOffset result = + PlayoutModeSchedulerBase.GetStartTimeAfter(state, scheduleItem, Option.None); + + result.ShouldBe(DateTimeOffset.Parse("2025-08-29T00:10:00-05:00")); + } } diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs index da75f807c..59df0d60f 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs @@ -7,6 +7,7 @@ using NSubstitute; using NUnit.Framework; using Serilog; using Shouldly; +using ILogger = Microsoft.Extensions.Logging.ILogger; namespace ErsatzTV.Core.Tests.Scheduling; @@ -756,7 +757,10 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase }; DateTimeOffset result = - PlayoutModeSchedulerBase.GetStartTimeAfter(state, scheduleItem); + PlayoutModeSchedulerBase.GetStartTimeAfter( + state, + scheduleItem, + Option.None); result.ShouldBe(DateTime.Today.AddHours(6)); } diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj index 7e4abb1c0..a292885f5 100644 --- a/ErsatzTV.Core/ErsatzTV.Core.csproj +++ b/ErsatzTV.Core/ErsatzTV.Core.csproj @@ -12,6 +12,7 @@ + diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 65977501b..a8a90c650 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Newtonsoft.Json; using Map = LanguageExt.Map; +using Humanizer; namespace ErsatzTV.Core.Scheduling; @@ -783,6 +784,26 @@ public class PlayoutBuilder : IPlayoutBuilder } } + // if (playoutItems.Count > 0 && result.AddedItems.Count > 0) + // { + // var gap = playoutItems.Min(pi => pi.StartOffset) - result.AddedItems.Max(pi => pi.FinishOffset); + // if (gap > TimeSpan.FromHours(1)) + // { + // _logger.LogWarning( + // "Large gap at {CurrentTime} ({Gap}) when scheduling item from schedule {Name} index {Index}", + // playoutBuilderState.CurrentTime, + // gap, + // activeSchedule.Name, + // scheduleItem.Index); + // + // _logger.LogWarning( + // "Start type: {StartType}, start time: {StartTime}, fixed start time behavior: {FixedStartTimeBehavior}", + // scheduleItem.StartType, + // scheduleItem.StartTime, + // scheduleItem.FixedStartTimeBehavior ?? activeSchedule.FixedStartTimeBehavior); + // } + // } + result.AddedItems.AddRange(playoutItems); playoutBuilderState = nextState; @@ -804,7 +825,7 @@ public class PlayoutBuilder : IPlayoutBuilder { ScheduleItemsEnumeratorState = playoutBuilderState.ScheduleItemsEnumerator.State, NextStart = PlayoutModeSchedulerBase - .GetStartTimeAfter(playoutBuilderState, anchorScheduleItem) + .GetStartTimeAfter(playoutBuilderState, anchorScheduleItem, Option.None) .UtcDateTime, InFlood = playoutBuilderState.InFlood, InDurationFiller = playoutBuilderState.InDurationFiller, @@ -842,21 +863,43 @@ public class PlayoutBuilder : IPlayoutBuilder DurationFinish = Option.None }; - DateTimeOffset nextStart = PlayoutModeSchedulerBase - .GetStartTimeAfter(cleanState, activeScheduleAtAnchor.Items.OrderBy(i => i.Index).Head()); + var firstItem = activeScheduleAtAnchor.Items.OrderBy(i => i.Index).Head(); + DateTimeOffset nextStart = PlayoutModeSchedulerBase.GetStartTimeAfter( + cleanState, + firstItem, + Option.Some(_logger)); if (playoutBuilderState.CurrentTime.TimeOfDay > TimeSpan.Zero) { - _logger.LogWarning( + _logger.LogDebug( "Playout build went beyond midnight ({Time}) into a different alternate schedule; this may cause issues with start times on the next day", playoutBuilderState.CurrentTime); } + // TimeSpan gap = nextStart - playoutBuilderState.CurrentTime; + // var fixedStartTimeBehavior = + // firstItem.FixedStartTimeBehavior ?? activeScheduleAtAnchor.FixedStartTimeBehavior; + // + // if (gap > TimeSpan.FromHours(1) && firstItem.StartTime.HasValue && fixedStartTimeBehavior == FixedStartTimeBehavior.Strict) + // { + // _logger.LogWarning( + // "Offline playout gap of {Gap} caused by strict fixed start time {StartTime} before current time {CurrentTime} on schedule {Name}", + // gap.Humanize(), + // firstItem.StartTime.Value, + // playoutBuilderState.CurrentTime.TimeOfDay, + // activeScheduleAtAnchor.Name); + // } + playout.Anchor.NextStart = nextStart.UtcDateTime; playout.Anchor.InFlood = false; playout.Anchor.InDurationFiller = false; playout.Anchor.MultipleRemaining = null; playout.Anchor.DurationFinish = null; + playout.Anchor.ScheduleItemsEnumeratorState = new CollectionEnumeratorState + { + Seed = playoutBuilderState.ScheduleItemsEnumerator.State.Seed, + Index = 0 + }; } // build program schedule anchors diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs index 2ece67e52..fc4c4608e 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs @@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Scheduling; +using Humanizer; using LanguageExt.UnsafeValueAccess; using Microsoft.Extensions.Logging; @@ -29,7 +30,7 @@ public abstract class PlayoutModeSchedulerBase : IPlayoutModeScheduler whe ProgramScheduleItem scheduleItem, DateTimeOffset hardStop) { - DateTimeOffset startTime = GetStartTimeAfter(state, scheduleItem); + DateTimeOffset startTime = GetStartTimeAfter(state, scheduleItem, Option.None); // filler should always stop at the hard stop if (hardStop < startTime) @@ -40,7 +41,10 @@ public abstract class PlayoutModeSchedulerBase : IPlayoutModeScheduler whe return startTime; } - public static DateTimeOffset GetStartTimeAfter(PlayoutBuilderState state, ProgramScheduleItem scheduleItem) + public static DateTimeOffset GetStartTimeAfter( + PlayoutBuilderState state, + ProgramScheduleItem scheduleItem, + Option maybeLogger) { DateTimeOffset startTime = state.CurrentTime.ToLocalTime(); @@ -92,6 +96,21 @@ public abstract class PlayoutModeSchedulerBase : IPlayoutModeScheduler whe startTime = startTime.TimeOfDay > itemStartTime ? result.AddDays(1) : result; break; } + + TimeSpan gap = startTime - state.CurrentTime; + if (gap > TimeSpan.FromHours(1) && fixedStartTimeBehavior == FixedStartTimeBehavior.Strict && + result.TimeOfDay < state.CurrentTime.ToLocalTime().TimeOfDay) + { + foreach (ILogger logger in maybeLogger) + { + logger.LogWarning( + "Offline playout gap of {Gap} caused by strict fixed start time {StartTime} before current time {CurrentTime} on schedule {Name}", + gap.Humanize(), + result.TimeOfDay, + state.CurrentTime.TimeOfDay, + scheduleItem.ProgramSchedule?.Name ?? "unknown"); + } + } } return startTime; diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs index b49e233a1..9eed6a69a 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs @@ -51,7 +51,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase.Some(Logger)); if (itemStartTime >= nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc) || // don't start if the first item will already be after the hard stop diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs index 381cc412d..9300eb050 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs @@ -38,7 +38,7 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase.Some(Logger)); if (itemStartTime >= hardStop) { scheduledNone = playoutItems.Count == 0; @@ -51,7 +51,7 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase.None) : DateTimeOffset.MaxValue; if (itemDuration == TimeSpan.Zero && mediaItem is RemoteStream) diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs index 7a0b4f80a..bfa599af2 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs @@ -24,7 +24,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase(); - DateTimeOffset firstStart = GetStartTimeAfter(playoutBuilderState, scheduleItem); + DateTimeOffset firstStart = GetStartTimeAfter(playoutBuilderState, scheduleItem, Option.Some(Logger)); if (firstStart >= hardStop) { playoutBuilderState = playoutBuilderState with { CurrentTime = hardStop }; @@ -84,7 +84,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase.Some(Logger)); TimeSpan itemDuration = DurationForMediaItem(mediaItem); List itemChapters = ChaptersForMediaItem(mediaItem); diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs index e92aa7a8c..3a02e9f6e 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs @@ -24,7 +24,10 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase.Some(Logger)); if (itemStartTime >= hardStop) { playoutBuilderState = playoutBuilderState with { CurrentTime = hardStop };