From b43d08ca67c8385ae5f804fb0b1f008e40b8af98 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Tue, 26 Jul 2022 13:04:05 -0500 Subject: [PATCH] fix repeating schedules (#901) --- CHANGELOG.md | 1 + .../ErsatzTV.Core.Tests.csproj | 1 + .../Scheduling/PlayoutBuilderTests.cs | 53 +++- .../Scheduling/ScheduleIntegrationTests.cs | 244 ++++++++++++++++++ ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 10 +- 5 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2675a4c8..38c41e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix NVIDIA second-gen maxwell capabilities detection - Return distinct search results for episodes and other videos that have the same title - For example, two other videos both named `Trailer` would previously have displayed as one item in search results +- Fix schedules that would begin to repeat the same content in the same order after a couple of days ### Added - Add `640x480` resolution diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj index dd1d5d18..d7ad3ccd 100644 --- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj +++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj @@ -31,6 +31,7 @@ + diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs index 5b0912db..e4a48c2e 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs @@ -2235,7 +2235,7 @@ public class PlayoutBuilderTests DateTimeOffset start2 = HoursAfterMidnight(0); DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6); - Playout result2 = await builder.Build(playout, PlayoutBuildMode.Continue, start2, finish2); + Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2); int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed; @@ -2244,6 +2244,57 @@ public class PlayoutBuilderTests result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0); } + [Test] + public async Task ShuffleFlood_Should_MaintainRandomSeed_MultipleDays() + { + var mediaItems = new List(); + for (int i = 1; i <= 25; i++) + { + mediaItems.Add(TestMovie(i, TimeSpan.FromMinutes(55), DateTime.Today.AddHours(i))); + } + + (PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Shuffle); + DateTimeOffset start = HoursAfterMidnight(0).AddSeconds(5); + DateTimeOffset finish = start + TimeSpan.FromDays(2); + + Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish); + + result.Items.Count.Should().Be(53); + result.ProgramScheduleAnchors.Count.Should().Be(2); + + result.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).Should().BeTrue(); + PlayoutProgramScheduleAnchor lastCheckpoint = result.ProgramScheduleAnchors + .OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue) + .First(); + lastCheckpoint.EnumeratorState.Seed.Should().BeGreaterThan(0); + lastCheckpoint.EnumeratorState.Index.Should().Be(3); + + // we need to mess up the ordering to trigger the problematic behavior + // this simulates the way the rows are loaded with EF + PlayoutProgramScheduleAnchor oldest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).Last(); + PlayoutProgramScheduleAnchor newest = result.ProgramScheduleAnchors.OrderByDescending(a => a.AnchorDate).First(); + + result.ProgramScheduleAnchors = new List + { + oldest, + newest + }; + + int firstSeedValue = lastCheckpoint.EnumeratorState.Seed; + + DateTimeOffset start2 = start.AddHours(1); + DateTimeOffset finish2 = start2 + TimeSpan.FromDays(2); + + Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2); + + PlayoutProgramScheduleAnchor continueAnchor = + result2.ProgramScheduleAnchors.First(x => x.AnchorDate is null); + int secondSeedValue = continueAnchor.EnumeratorState.Seed; + + // the continue anchor should have the same seed as the most recent (last) checkpoint from the first run + firstSeedValue.Should().Be(secondSeedValue); + } + [Test] public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor() { diff --git a/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs new file mode 100644 index 00000000..a8039996 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs @@ -0,0 +1,244 @@ +using Dapper; +using Destructurama; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Infrastructure.Extensions; +using LanguageExt.UnsafeValueAccess; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Serilog; +using Serilog.Events; +using Serilog.Extensions.Logging; + +namespace ErsatzTV.Core.Tests.Scheduling; + +[TestFixture] +[Explicit] +public class ScheduleIntegrationTests +{ + public ScheduleIntegrationTests() + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning) + .WriteTo.Console() + .Destructure.UsingAttributes() + .CreateLogger(); + } + + [Test] + public async Task Test() + { + string dbFileName = Path.GetTempFileName() + ".sqlite3"; + + IServiceCollection services = new ServiceCollection() + .AddLogging(); + + var connectionString = $"Data Source={dbFileName};foreign keys=true;"; + + services.AddDbContext( + options => options.UseSqlite( + connectionString, + o => + { + o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + o.MigrationsAssembly("ErsatzTV.Infrastructure"); + }), + ServiceLifetime.Scoped, + ServiceLifetime.Singleton); + + services.AddDbContextFactory( + options => options.UseSqlite( + connectionString, + o => + { + o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + o.MigrationsAssembly("ErsatzTV.Infrastructure"); + })); + + SqlMapper.AddTypeHandler(new DateTimeOffsetHandler()); + SqlMapper.AddTypeHandler(new GuidHandler()); + SqlMapper.AddTypeHandler(new TimeSpanHandler()); + + services.AddSingleton((Func)(_ => new SerilogLoggerFactory())); + + ServiceProvider provider = services.BuildServiceProvider(); + + IDbContextFactory factory = provider.GetRequiredService>(); + + ILogger logger = provider.GetRequiredService>(); + logger.LogInformation("Database is at {File}", dbFileName); + + await using TvContext dbContext = await factory.CreateDbContextAsync(CancellationToken.None); + await dbContext.Database.MigrateAsync(CancellationToken.None); + await DbInitializer.Initialize(dbContext, CancellationToken.None); + + var path = new LibraryPath + { + Path = "Test LibraryPath" + }; + + var library = new LocalLibrary + { + MediaKind = LibraryMediaKind.Movies, + Paths = new List { path }, + MediaSource = new LocalMediaSource() + }; + + await dbContext.Libraries.AddAsync(library); + await dbContext.SaveChangesAsync(); + + var movies = new List(); + for (var i = 1; i < 25; i++) + { + var movie = new Movie + { + MediaVersions = new List + { + new() { Duration = TimeSpan.FromMinutes(55) } + }, + MovieMetadata = new List + { + new() + { + Title = $"Movie {i}", + ReleaseDate = new DateTime(2000, 1, 1).AddDays(i) + } + }, + LibraryPath = path, + LibraryPathId = path.Id + }; + + movies.Add(movie); + } + + await dbContext.Movies.AddRangeAsync(movies); + await dbContext.SaveChangesAsync(); + + var collection = new Collection + { + Name = "Test Collection", + MediaItems = movies.Cast().ToList() + }; + + await dbContext.Collections.AddAsync(collection); + await dbContext.SaveChangesAsync(); + + var scheduleItems = new List + { + new ProgramScheduleItemDuration + { + Collection = collection, + CollectionId = collection.Id, + PlayoutDuration = TimeSpan.FromHours(1), + TailMode = TailMode.None, // immediately continue + PlaybackOrder = PlaybackOrder.Shuffle + } + }; + + int playoutId = await AddTestData(dbContext, scheduleItems); + + DateTimeOffset start = new DateTimeOffset(2022, 7, 26, 8, 0, 5, TimeSpan.FromHours(-5)); + DateTimeOffset finish = start.AddDays(2); + + var builder = new PlayoutBuilder( + new ConfigElementRepository(factory), + new MediaCollectionRepository(new Mock().Object, factory), + new TelevisionRepository(factory), + new ArtistRepository(factory), + provider.GetRequiredService>()); + + for (var i = 0; i <= (24 * 4); i++) + { + await using TvContext context = await factory.CreateDbContextAsync(); + + Option maybePlayout = await GetPlayout(context, playoutId); + Playout playout = maybePlayout.ValueUnsafe(); + + await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i)); + + await context.SaveChangesAsync(); + } + } + + private static async Task AddTestData(TvContext dbContext, List scheduleItems) + { + var ffmpegProfile = new FFmpegProfile + { + Name = "Test FFmpeg Profile" + }; + + await dbContext.FFmpegProfiles.AddAsync(ffmpegProfile); + await dbContext.SaveChangesAsync(); + + var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001")) + { + Name = "Test Channel", + FFmpegProfile = ffmpegProfile, + FFmpegProfileId = ffmpegProfile.Id + }; + + await dbContext.Channels.AddAsync(channel); + await dbContext.SaveChangesAsync(); + + var schedule = new ProgramSchedule + { + Name = "Test Schedule", + Items = scheduleItems + }; + + await dbContext.ProgramSchedules.AddAsync(schedule); + await dbContext.SaveChangesAsync(); + + var playout = new Playout + { + Channel = channel, + ChannelId = channel.Id, + ProgramSchedule = schedule, + ProgramScheduleId = schedule.Id + }; + + await dbContext.Playouts.AddAsync(playout); + await dbContext.SaveChangesAsync(); + + return playout.Id; + } + + private static async Task> GetPlayout(TvContext dbContext, int playoutId) + { + return await dbContext.Playouts + .Include(p => p.Channel) + .Include(p => p.Items) + .Include(p => p.ProgramScheduleAnchors) + .ThenInclude(a => a.MediaItem) + .Include(p => p.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.Collection) + .Include(p => p.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.MediaItem) + .Include(p => p.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.PreRollFiller) + .Include(p => p.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.MidRollFiller) + .Include(p => p.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.PostRollFiller) + .Include(p => p.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.TailFiller) + .Include(p => p.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.FallbackFiller) + .SelectOneAsync(p => p.Id, p => p.Id == playoutId); + } +} diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 20b83a0b..d0bf0041 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -626,9 +626,7 @@ public class PlayoutBuilder : IPlayoutBuilder && a.MediaItemId == collectionKey.MediaItemId && a.AnchorDate is null); - var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary( - mcs => mcs.Key, - mcs => mcs.Head()); + var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State); PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match( existing => @@ -658,10 +656,10 @@ public class PlayoutBuilder : IPlayoutBuilder result.Add(scheduleAnchor); } - foreach (PlayoutProgramScheduleAnchor continueAnchor in playout.ProgramScheduleAnchors.Where( + foreach (PlayoutProgramScheduleAnchor checkpointAnchor in playout.ProgramScheduleAnchors.Where( a => a.AnchorDate is not null)) { - result.Add(continueAnchor); + result.Add(checkpointAnchor); } return result; @@ -675,7 +673,7 @@ public class PlayoutBuilder : IPlayoutBuilder bool randomStartPoint) { Option maybeAnchor = playout.ProgramScheduleAnchors - .OrderByDescending(a => a.AnchorDate is null) + .OrderByDescending(a => a.AnchorDate ?? DateTime.MaxValue) .FirstOrDefault( a => a.ProgramScheduleId == playout.ProgramScheduleId && a.CollectionType == collectionKey.CollectionType