From bc845b13274c80457c0bce24fb1ae8b065a92581 Mon Sep 17 00:00:00 2001
From: Jason Dove <1695733+jasongdove@users.noreply.github.com>
Date: Sat, 30 Sep 2023 06:41:15 -0500
Subject: [PATCH] schedule filler using ticks instead of milliseconds (#1454)

* add script to set db provider

* don't extract embedded subtitles with DEBUG_NO_SYNC

* fix playout filler precision bug
---
 CHANGELOG.md                                  |   2 +
 .../PlayoutModeSchedulerDurationTests.cs      | 150 ++++++++++++++++--
 .../Scheduling/SchedulerTestBase.cs           |  33 ++++
 .../Scheduling/PlayoutModeSchedulerBase.cs    |  13 +-
 .../PlayoutModeSchedulerDuration.cs           |   2 +-
 ErsatzTV/Services/WorkerService.cs            |   2 +
 scripts/set-provider.sh                       |  12 ++
 7 files changed, 193 insertions(+), 21 deletions(-)
 create mode 100755 scripts/set-provider.sh

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 022f2079..f9cdf8f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
 ### Fixed
 - Fix playout bug that caused some schedule items with fixed start times to be pushed to the next day
+- Fix playout bug that prevented padded durations from fitting within a schedule item of the same duration
+  - For example, filler that padded to 30 minutes would often not fit in a 30 minute duration schedule item
 - Fix VAAPI transcoding 8-bit source content to 10-bit
 - Fix NVIDIA subtitle scaling when `scale_npp` filter is unavailable
 - Remove ffmpeg and ffprobe as required dependencies for scanning media server libraries
diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerDurationTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerDurationTests.cs
index cd22f043..0290bcc9 100644
--- a/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerDurationTests.cs
+++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerDurationTests.cs
@@ -1,10 +1,11 @@
-using ErsatzTV.Core.Domain;
+using Destructurama;
+using ErsatzTV.Core.Domain;
 using ErsatzTV.Core.Domain.Filler;
 using ErsatzTV.Core.Scheduling;
 using FluentAssertions;
 using Microsoft.Extensions.Logging;
-using NSubstitute;
 using NUnit.Framework;
+using Serilog;
 
 namespace ErsatzTV.Core.Tests.Scheduling;
 
@@ -15,6 +16,20 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
     public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
 
     private CancellationToken _cancellationToken;
+    private readonly ILogger<PlayoutModeSchedulerDuration> _logger;
+    
+    public PlayoutModeSchedulerDurationTests()
+    {
+        Log.Logger = new LoggerConfiguration()
+            .MinimumLevel.Debug()
+            .WriteTo.Console()
+            .Destructure.UsingAttributes()
+            .CreateLogger();
+
+        ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
+
+        _logger = loggerFactory.CreateLogger<PlayoutModeSchedulerDuration>();
+    }
 
     [Test]
     public void Should_Fill_Exact_Duration()
@@ -44,7 +59,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
 
         PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
 
-        var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
         (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
             startState,
             CollectionEnumerators(scheduleItem, enumerator),
@@ -117,7 +132,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
 
         PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
 
-        var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
         (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
             startState,
             CollectionEnumerators(scheduleItem, enumerator),
@@ -189,7 +204,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
 
         PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
 
-        var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
         (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
             startState,
             CollectionEnumerators(scheduleItem, enumerator),
@@ -258,7 +273,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
 
         PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
 
-        var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
         (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
             startState,
             CollectionEnumerators(scheduleItem, enumerator),
@@ -341,7 +356,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
 
         PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
 
-        var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
         (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
             startState,
             CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.FallbackFiller, enumerator2),
@@ -428,7 +443,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
 
         PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
 
-        var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
         (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
             startState,
             CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
@@ -527,7 +542,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
 
         PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
 
-        var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
         (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
             startState,
             CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2),
@@ -637,7 +652,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
 
         PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
 
-        var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
         (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
             startState,
             CollectionEnumerators(
@@ -710,6 +725,119 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
         playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback);
         playoutItems[6].GuideFinish.HasValue.Should().BeFalse();
     }
+    
+    [Test]
+    public void Should_Not_Have_Gap_With_Post_Roll_Pad_And_Fallback_Filler()
+    {
+        Collection collectionPre = TwoItemCollection(1, 2, TimeSpan.Parse("00:00:15.6734470"));
+        Collection collectionOne = TwoItemCollection(3, 4, TimeSpan.Parse("00:22:58.1220000"));
+        Collection collectionTwo = CollectionOf(
+            new Dictionary<int, TimeSpan>
+            {
+                { 5, TimeSpan.Parse("00:00:31.3004760") },
+                { 6, TimeSpan.Parse("00:00:31.7880950") },
+                { 7, TimeSpan.Parse("00:00:31.1147170") },
+                { 8, TimeSpan.Parse("00:00:46.4863270") },
+                { 9, TimeSpan.Parse("00:00:31.4165760") },
+                { 10, TimeSpan.Parse("00:00:31.5791160") },
+                { 11, TimeSpan.Parse("00:00:31.2540360") },
+                { 12, TimeSpan.Parse("00:00:36.2231070") },
+                { 13, TimeSpan.Parse("00:02:00.0471430") },
+            });
+        Collection collectionThree = TwoItemCollection(14, 15, TimeSpan.Parse("00:00:55.6349890"));
+
+        var scheduleItem = new ProgramScheduleItemDuration
+        {
+            Id = 1,
+            Index = 1,
+            Collection = collectionOne,
+            CollectionId = collectionOne.Id,
+            StartTime = null,
+            PlayoutDuration = TimeSpan.FromMinutes(30),
+            PlaybackOrder = PlaybackOrder.Chronological,
+            PreRollFiller = new FillerPreset
+            {
+                FillerKind = FillerKind.PreRoll,
+                FillerMode = FillerMode.Count,
+                Count = 1,
+                Collection = collectionPre,
+                CollectionId = collectionPre.Id
+            },
+            PostRollFiller = new FillerPreset
+            {
+                FillerKind = FillerKind.PostRoll,
+                FillerMode = FillerMode.Pad,
+                PadToNearestMinute = 30,
+                Collection = collectionTwo,
+                CollectionId = collectionTwo.Id
+            },
+            FallbackFiller = new FillerPreset
+            {
+                FillerKind = FillerKind.Fallback,
+                Collection = collectionThree,
+                CollectionId = collectionThree.Id
+            }
+        };
+
+        var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
+            new List<ProgramScheduleItem> { scheduleItem },
+            new CollectionEnumeratorState());
+
+        var enumerator1 = new ChronologicalMediaCollectionEnumerator(
+            collectionPre.MediaItems,
+            new CollectionEnumeratorState());
+
+        var enumerator2 = new ChronologicalMediaCollectionEnumerator(
+            collectionOne.MediaItems,
+            new CollectionEnumeratorState());
+
+        var enumerator3 = new ChronologicalMediaCollectionEnumerator(
+            collectionTwo.MediaItems,
+            new CollectionEnumeratorState());
+
+        var enumerator4 = new ChronologicalMediaCollectionEnumerator(
+            collectionThree.MediaItems,
+            new CollectionEnumeratorState());
+
+        PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
+
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
+        (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
+            startState,
+            CollectionEnumerators(
+                scheduleItem,
+                enumerator2,
+                scheduleItem.PreRollFiller,
+                enumerator1,
+                scheduleItem.PostRollFiller,
+                enumerator3,
+                scheduleItem.FallbackFiller,
+                enumerator4),
+            scheduleItem,
+            NextScheduleItem,
+            HardStop(scheduleItemsEnumerator),
+            _cancellationToken);
+
+        playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddMinutes(30));
+        playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
+        
+        // THIS IS THE KEY TEST - needs to be exactly 30 minutes
+        (playoutItems.Last().FinishOffset - playoutItems.First().StartOffset).Should().Be(TimeSpan.FromMinutes(30));
+
+        // playoutBuilderState.NextGuideGroup.Should().Be(3);
+        playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
+        playoutBuilderState.InFlood.Should().BeFalse();
+        playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
+        playoutBuilderState.InDurationFiller.Should().BeFalse();
+        playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
+
+        enumerator1.State.Index.Should().Be(1);
+        enumerator2.State.Index.Should().Be(1);
+        enumerator3.State.Index.Should().Be(0);
+        enumerator4.State.Index.Should().Be(1);
+
+        playoutItems.Count.Should().Be(12);
+    }
 
     [Test]
     public void Should_Not_Schedule_At_HardStop()
@@ -745,7 +873,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
 
         PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
 
-        var scheduler = new PlayoutModeSchedulerDuration(Substitute.For<ILogger>());
+        var scheduler = new PlayoutModeSchedulerDuration(_logger);
         (PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
             startState,
             CollectionEnumerators(scheduleItem, enumerator),
diff --git a/ErsatzTV.Core.Tests/Scheduling/SchedulerTestBase.cs b/ErsatzTV.Core.Tests/Scheduling/SchedulerTestBase.cs
index b1fc2501..c8bd3378 100644
--- a/ErsatzTV.Core.Tests/Scheduling/SchedulerTestBase.cs
+++ b/ErsatzTV.Core.Tests/Scheduling/SchedulerTestBase.cs
@@ -45,6 +45,23 @@ public abstract class SchedulerTestBase
             { CollectionKey.ForFillerPreset(fillerPreset), enumerator2 },
             { CollectionKey.ForFillerPreset(fillerPreset2), enumerator3 }
         };
+    
+    protected static Dictionary<CollectionKey, IMediaCollectionEnumerator> CollectionEnumerators(
+        ProgramScheduleItem scheduleItem,
+        IMediaCollectionEnumerator enumerator1,
+        FillerPreset fillerPreset,
+        IMediaCollectionEnumerator enumerator2,
+        FillerPreset fillerPreset2,
+        IMediaCollectionEnumerator enumerator3,
+        FillerPreset fillerPreset3,
+        IMediaCollectionEnumerator enumerator4) =>
+        new()
+        {
+            { CollectionKey.ForScheduleItem(scheduleItem), enumerator1 },
+            { CollectionKey.ForFillerPreset(fillerPreset), enumerator2 },
+            { CollectionKey.ForFillerPreset(fillerPreset2), enumerator3 },
+            { CollectionKey.ForFillerPreset(fillerPreset3), enumerator4 }
+        };
 
     private static Movie TestMovie(int id, TimeSpan duration, DateTime aired, int chapterCount = 0)
     {
@@ -81,6 +98,22 @@ public abstract class SchedulerTestBase
             TestMovie(id2, duration, new DateTime(2020, 1, 2), chapterCount)
         }
     };
+    
+    protected static Collection CollectionOf(IDictionary<int, TimeSpan> idsAndDurations, int chapterCount = 0)
+    {
+        var mediaItems = new List<MediaItem>();
+        foreach ((int id, TimeSpan duration) in idsAndDurations)
+        {
+            mediaItems.Add(TestMovie(id, duration, new DateTime(2020, 1, id), chapterCount));
+        }
+
+        return new Collection
+        {
+            Id = idsAndDurations.Head().Key,
+            Name = $"Collection of Items {idsAndDurations.Head().Key}",
+            MediaItems = mediaItems
+        };
+    }
 
     protected static Dictionary<CollectionKey, IMediaCollectionEnumerator> CollectionEnumerators(
         ProgramScheduleItem scheduleItem,
diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
index da47800c..706111cd 100644
--- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
+++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
@@ -41,7 +41,6 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
     public static DateTimeOffset GetStartTimeAfter(PlayoutBuilderState state, ProgramScheduleItem scheduleItem)
     {
         DateTimeOffset startTime = state.CurrentTime.ToLocalTime();
-        startTime = startTime.AddTicks(-(startTime.Ticks % TimeSpan.TicksPerSecond));
 
         bool isIncomplete = scheduleItem is ProgramScheduleItemMultiple && state.MultipleRemaining.IsSome ||
                             scheduleItem is ProgramScheduleItemDuration && state.DurationFinish.IsSome ||
@@ -51,7 +50,6 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
         if (scheduleItem.StartType == StartType.Fixed && !isIncomplete)
         {
             TimeSpan itemStartTime = scheduleItem.StartTime.GetValueOrDefault();
-            itemStartTime = TimeSpan.FromMilliseconds((int)itemStartTime.TotalMilliseconds);
 
             DateTime date = startTime.Date;
             DateTimeOffset result = new DateTimeOffset(
@@ -352,13 +350,12 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
         foreach (FillerPreset padFiller in Optional(
                      allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
         {
-            var totalDuration = TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
+            var totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks));
 
             // add primary content to totalDuration only if it hasn't already been added
             if (result.All(pi => pi.MediaItemId != playoutItem.MediaItemId))
             {
-                totalDuration += TimeSpan.FromMilliseconds(
-                    effectiveChapters.Sum(c => (c.EndTime - c.StartTime).TotalMilliseconds));
+                totalDuration += TimeSpan.FromTicks(effectiveChapters.Sum(c => (c.EndTime - c.StartTime).Ticks));
             }
 
             int currentMinute = (playoutItem.StartOffset + totalDuration).Minute;
@@ -402,8 +399,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
                             padFiller.AllowWatermarks,
                             log,
                             cancellationToken));
-                    totalDuration =
-                        TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
+                    totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks));
                     remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
                     if (remainingToFill > TimeSpan.Zero)
                     {
@@ -493,8 +489,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
                             padFiller.AllowWatermarks,
                             log,
                             cancellationToken));
-                    totalDuration =
-                        TimeSpan.FromMilliseconds(result.Sum(pi => (pi.Finish - pi.Start).TotalMilliseconds));
+                    totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks));
                     remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
                     if (remainingToFill > TimeSpan.Zero)
                     {
diff --git a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
index 3d2259df..09f72562 100644
--- a/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
+++ b/ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
@@ -167,7 +167,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
                     }
 
                     TimeSpan durationBlock = itemEndTimeWithFiller - itemStartTime;
-                    if (itemEndTimeWithFiller - itemStartTime > scheduleItem.PlayoutDuration)
+                    if (durationBlock > scheduleItem.PlayoutDuration)
                     {
                         Logger.LogWarning(
                             "Unable to schedule duration block of {DurationBlock:hh\\:mm\\:ss} which is longer than the configured playout duration {PlayoutDuration:hh\\:mm\\:ss}",
diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs
index e9dce2e8..f0ad7220 100644
--- a/ErsatzTV/Services/WorkerService.cs
+++ b/ErsatzTV/Services/WorkerService.cs
@@ -86,9 +86,11 @@ public class WorkerService : BackgroundService
                         case MatchTraktListItems matchTraktListItems:
                             await mediator.Send(matchTraktListItems, stoppingToken);
                             break;
+#if !DEBUG_NO_SYNC
                         case ExtractEmbeddedSubtitles extractEmbeddedSubtitles:
                             await mediator.Send(extractEmbeddedSubtitles, stoppingToken);
                             break;
+#endif
                         case ReleaseMemory aggressivelyReleaseMemory:
                             await mediator.Send(aggressivelyReleaseMemory, stoppingToken);
                             break;
diff --git a/scripts/set-provider.sh b/scripts/set-provider.sh
new file mode 100755
index 00000000..1a38b43a
--- /dev/null
+++ b/scripts/set-provider.sh
@@ -0,0 +1,12 @@
+#! /usr/bin/env bash
+
+if [[ $# -eq 0 ]] ; then
+    echo 'Please specify a database provider'
+    exit 1
+fi
+
+cd "$(git rev-parse --show-cdup)" || exit
+cd ErsatzTV && dotnet user-secrets set "Provider" "$1"
+
+cd "$(git rev-parse --show-cdup)" || exit
+cd ErsatzTV.Scanner && dotnet user-secrets set "Provider" "$1"