Browse Source

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
pull/2355/head
Jason Dove 9 months ago committed by GitHub
parent
commit
8a1cf72209
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 9
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs
  3. 34
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/GetStartTimeAfterTests.cs
  4. 6
      ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs
  5. 1
      ErsatzTV.Core/ErsatzTV.Core.csproj
  6. 51
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  7. 23
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
  8. 2
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
  9. 4
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs
  10. 4
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs
  11. 5
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs

2
CHANGELOG.md

@ -85,6 +85,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

9
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs

@ -39,7 +39,14 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase @@ -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);

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

@ -1,5 +1,6 @@ @@ -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 @@ -26,8 +27,39 @@ public class GetStartTimeAfterTests
0,
DateTimeOffset.Parse("2025-11-02T00:00:00-05:00"));
DateTimeOffset result = PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(state, scheduleItem);
DateTimeOffset result =
PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(state, scheduleItem, Option<ILogger>.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<int>.None,
Option<DateTimeOffset>.None,
false,
false,
0,
DateTimeOffset.Parse("2025-08-29T00:10:00-05:00"));
DateTimeOffset result =
PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(state, scheduleItem, Option<ILogger>.None);
result.ShouldBe(DateTimeOffset.Parse("2025-08-29T00:10:00-05:00"));
}
}

6
ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs

@ -7,6 +7,7 @@ using NSubstitute; @@ -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 @@ -756,7 +757,10 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
};
DateTimeOffset result =
PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(state, scheduleItem);
PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(
state,
scheduleItem,
Option<ILogger>.None);
result.ShouldBe(DateTime.Today.AddHours(6));
}

1
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -12,6 +12,7 @@ @@ -12,6 +12,7 @@
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="Destructurama.Attributed" Version="5.1.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="IronPython" Version="3.4.2" />
<PackageReference Include="IronPython.StdLib" Version="3.4.2" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />

51
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; @@ -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 @@ -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 @@ -804,7 +825,7 @@ public class PlayoutBuilder : IPlayoutBuilder
{
ScheduleItemsEnumeratorState = playoutBuilderState.ScheduleItemsEnumerator.State,
NextStart = PlayoutModeSchedulerBase<ProgramScheduleItem>
.GetStartTimeAfter(playoutBuilderState, anchorScheduleItem)
.GetStartTimeAfter(playoutBuilderState, anchorScheduleItem, Option<ILogger>.None)
.UtcDateTime,
InFlood = playoutBuilderState.InFlood,
InDurationFiller = playoutBuilderState.InDurationFiller,
@ -842,21 +863,43 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -842,21 +863,43 @@ public class PlayoutBuilder : IPlayoutBuilder
DurationFinish = Option<DateTimeOffset>.None
};
DateTimeOffset nextStart = PlayoutModeSchedulerBase<ProgramScheduleItem>
.GetStartTimeAfter(cleanState, activeScheduleAtAnchor.Items.OrderBy(i => i.Index).Head());
var firstItem = activeScheduleAtAnchor.Items.OrderBy(i => i.Index).Head();
DateTimeOffset nextStart = PlayoutModeSchedulerBase<ProgramScheduleItem>.GetStartTimeAfter(
cleanState,
firstItem,
Option<ILogger>.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

23
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain; @@ -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<T> : IPlayoutModeScheduler<T> whe @@ -29,7 +30,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
ProgramScheduleItem scheduleItem,
DateTimeOffset hardStop)
{
DateTimeOffset startTime = GetStartTimeAfter(state, scheduleItem);
DateTimeOffset startTime = GetStartTimeAfter(state, scheduleItem, Option<ILogger>.None);
// filler should always stop at the hard stop
if (hardStop < startTime)
@ -40,7 +41,10 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe @@ -40,7 +41,10 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
return startTime;
}
public static DateTimeOffset GetStartTimeAfter(PlayoutBuilderState state, ProgramScheduleItem scheduleItem)
public static DateTimeOffset GetStartTimeAfter(
PlayoutBuilderState state,
ProgramScheduleItem scheduleItem,
Option<ILogger> maybeLogger)
{
DateTimeOffset startTime = state.CurrentTime.ToLocalTime();
@ -92,6 +96,21 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe @@ -92,6 +96,21 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> 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;

2
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs

@ -51,7 +51,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche @@ -51,7 +51,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
MediaItem mediaItem = contentEnumerator.Current.ValueUnsafe();
// find when we should start this item, based on the current time
DateTimeOffset itemStartTime = GetStartTimeAfter(nextState, scheduleItem);
DateTimeOffset itemStartTime = GetStartTimeAfter(nextState, scheduleItem, Option<ILogger>.Some(Logger));
if (itemStartTime >= nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc) ||
// don't start if the first item will already be after the hard stop

4
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs

@ -38,7 +38,7 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul @@ -38,7 +38,7 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
MediaItem mediaItem = contentEnumerator.Current.ValueUnsafe();
// find when we should start this item, based on the current time
DateTimeOffset itemStartTime = GetStartTimeAfter(nextState, scheduleItem);
DateTimeOffset itemStartTime = GetStartTimeAfter(nextState, scheduleItem, Option<ILogger>.Some(Logger));
if (itemStartTime >= hardStop)
{
scheduledNone = playoutItems.Count == 0;
@ -51,7 +51,7 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul @@ -51,7 +51,7 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
// never block scheduling when there is only one schedule item (with fixed start and flood)
DateTimeOffset peekScheduleItemStart =
scheduleItem.Id != peekScheduleItem.Id && peekScheduleItem.StartType == StartType.Fixed
? GetStartTimeAfter(nextState with { InFlood = false }, peekScheduleItem)
? GetStartTimeAfter(nextState with { InFlood = false }, peekScheduleItem, Option<ILogger>.None)
: DateTimeOffset.MaxValue;
if (itemDuration == TimeSpan.Zero && mediaItem is RemoteStream)

4
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs

@ -24,7 +24,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche @@ -24,7 +24,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
{
var playoutItems = new List<PlayoutItem>();
DateTimeOffset firstStart = GetStartTimeAfter(playoutBuilderState, scheduleItem);
DateTimeOffset firstStart = GetStartTimeAfter(playoutBuilderState, scheduleItem, Option<ILogger>.Some(Logger));
if (firstStart >= hardStop)
{
playoutBuilderState = playoutBuilderState with { CurrentTime = hardStop };
@ -84,7 +84,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche @@ -84,7 +84,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
MediaItem mediaItem = contentEnumerator.Current.ValueUnsafe();
// find when we should start this item, based on the current time
DateTimeOffset itemStartTime = GetStartTimeAfter(nextState, scheduleItem);
DateTimeOffset itemStartTime = GetStartTimeAfter(nextState, scheduleItem, Option<ILogger>.Some(Logger));
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
List<MediaChapter> itemChapters = ChaptersForMediaItem(mediaItem);

5
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs

@ -24,7 +24,10 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI @@ -24,7 +24,10 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
foreach (MediaItem mediaItem in contentEnumerator.Current)
{
// find when we should start this item, based on the current time
DateTimeOffset itemStartTime = GetStartTimeAfter(playoutBuilderState, scheduleItem);
DateTimeOffset itemStartTime = GetStartTimeAfter(
playoutBuilderState,
scheduleItem,
Option<ILogger>.Some(Logger));
if (itemStartTime >= hardStop)
{
playoutBuilderState = playoutBuilderState with { CurrentTime = hardStop };

Loading…
Cancel
Save