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