fix repeating schedules (#901)
This commit is contained in:
@@ -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
|
- Fix NVIDIA second-gen maxwell capabilities detection
|
||||||
- Return distinct search results for episodes and other videos that have the same title
|
- 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
|
- 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
|
### Added
|
||||||
- Add `640x480` resolution
|
- Add `640x480` resolution
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -2235,7 +2235,7 @@ public class PlayoutBuilderTests
|
|||||||
DateTimeOffset start2 = HoursAfterMidnight(0);
|
DateTimeOffset start2 = HoursAfterMidnight(0);
|
||||||
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
|
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;
|
int secondSeedValue = result2.ProgramScheduleAnchors.Head().EnumeratorState.Seed;
|
||||||
|
|
||||||
@@ -2244,6 +2244,57 @@ public class PlayoutBuilderTests
|
|||||||
result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0);
|
result2.ProgramScheduleAnchors.Head().EnumeratorState.Index.Should().Be(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ShuffleFlood_Should_MaintainRandomSeed_MultipleDays()
|
||||||
|
{
|
||||||
|
var mediaItems = new List<MediaItem>();
|
||||||
|
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<PlayoutProgramScheduleAnchor>
|
||||||
|
{
|
||||||
|
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]
|
[Test]
|
||||||
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
|
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<TvContext>(
|
||||||
|
options => options.UseSqlite(
|
||||||
|
connectionString,
|
||||||
|
o =>
|
||||||
|
{
|
||||||
|
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||||
|
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||||
|
}),
|
||||||
|
ServiceLifetime.Scoped,
|
||||||
|
ServiceLifetime.Singleton);
|
||||||
|
|
||||||
|
services.AddDbContextFactory<TvContext>(
|
||||||
|
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<IServiceProvider, ILoggerFactory>)(_ => new SerilogLoggerFactory()));
|
||||||
|
|
||||||
|
ServiceProvider provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
|
||||||
|
|
||||||
|
ILogger<ScheduleIntegrationTests> logger = provider.GetRequiredService<ILogger<ScheduleIntegrationTests>>();
|
||||||
|
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<LibraryPath> { path },
|
||||||
|
MediaSource = new LocalMediaSource()
|
||||||
|
};
|
||||||
|
|
||||||
|
await dbContext.Libraries.AddAsync(library);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var movies = new List<Movie>();
|
||||||
|
for (var i = 1; i < 25; i++)
|
||||||
|
{
|
||||||
|
var movie = new Movie
|
||||||
|
{
|
||||||
|
MediaVersions = new List<MediaVersion>
|
||||||
|
{
|
||||||
|
new() { Duration = TimeSpan.FromMinutes(55) }
|
||||||
|
},
|
||||||
|
MovieMetadata = new List<MovieMetadata>
|
||||||
|
{
|
||||||
|
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<MediaItem>().ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
await dbContext.Collections.AddAsync(collection);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var scheduleItems = new List<ProgramScheduleItem>
|
||||||
|
{
|
||||||
|
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<ISearchIndex>().Object, factory),
|
||||||
|
new TelevisionRepository(factory),
|
||||||
|
new ArtistRepository(factory),
|
||||||
|
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
|
||||||
|
|
||||||
|
for (var i = 0; i <= (24 * 4); i++)
|
||||||
|
{
|
||||||
|
await using TvContext context = await factory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
Option<Playout> 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<int> AddTestData(TvContext dbContext, List<ProgramScheduleItem> 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<Option<Playout>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -626,9 +626,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
|||||||
&& a.MediaItemId == collectionKey.MediaItemId
|
&& a.MediaItemId == collectionKey.MediaItemId
|
||||||
&& a.AnchorDate is null);
|
&& a.AnchorDate is null);
|
||||||
|
|
||||||
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary(
|
var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State);
|
||||||
mcs => mcs.Key,
|
|
||||||
mcs => mcs.Head());
|
|
||||||
|
|
||||||
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match(
|
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match(
|
||||||
existing =>
|
existing =>
|
||||||
@@ -658,10 +656,10 @@ public class PlayoutBuilder : IPlayoutBuilder
|
|||||||
result.Add(scheduleAnchor);
|
result.Add(scheduleAnchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (PlayoutProgramScheduleAnchor continueAnchor in playout.ProgramScheduleAnchors.Where(
|
foreach (PlayoutProgramScheduleAnchor checkpointAnchor in playout.ProgramScheduleAnchors.Where(
|
||||||
a => a.AnchorDate is not null))
|
a => a.AnchorDate is not null))
|
||||||
{
|
{
|
||||||
result.Add(continueAnchor);
|
result.Add(checkpointAnchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -675,7 +673,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
|||||||
bool randomStartPoint)
|
bool randomStartPoint)
|
||||||
{
|
{
|
||||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
|
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
|
||||||
.OrderByDescending(a => a.AnchorDate is null)
|
.OrderByDescending(a => a.AnchorDate ?? DateTime.MaxValue)
|
||||||
.FirstOrDefault(
|
.FirstOrDefault(
|
||||||
a => a.ProgramScheduleId == playout.ProgramScheduleId
|
a => a.ProgramScheduleId == playout.ProgramScheduleId
|
||||||
&& a.CollectionType == collectionKey.CollectionType
|
&& a.CollectionType == collectionKey.CollectionType
|
||||||
|
|||||||
Reference in New Issue
Block a user