From f26e48c063d7e15fbe9dcf7b4f837d67400859e1 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Wed, 17 Sep 2025 08:53:50 -0500 Subject: [PATCH] block schedules: skip items and collections that will never fit (#2433) * add first block playout builder test * block schedules: skip items and collections that will never fit --- CHANGELOG.md | 2 + .../BlockPlayoutBuilderTests.cs | 476 ++++++++++++++++++ .../BlockScheduling/BlockPlayoutBuilder.cs | 230 +++++---- 3 files changed, 604 insertions(+), 104 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Scheduling/BlockScheduling/BlockPlayoutBuilderTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df36adda..7d466992a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - This can happen when NVIDIA accel falls back to libx264 software encoder for 10-bit h264 output - Fix 10-bit output when using NVIDIA and graphics engine (watermark or other overlays) - Fix playback of Jellyfin content with unknown color range +- Block schedules: skip collections (block items) that will never fit in block duration +- Block schedules: skip media items that will never fit in block duration ## [25.6.0] - 2025-09-14 ### Added diff --git a/ErsatzTV.Core.Tests/Scheduling/BlockScheduling/BlockPlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/BlockScheduling/BlockPlayoutBuilderTests.cs new file mode 100644 index 000000000..d3c89aeb3 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/BlockScheduling/BlockPlayoutBuilderTests.cs @@ -0,0 +1,476 @@ +using Destructurama; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Core.Scheduling.BlockScheduling; +using ErsatzTV.Core.Tests.Fakes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using Serilog; +using Shouldly; + +namespace ErsatzTV.Core.Tests.Scheduling.BlockScheduling; + +public class BlockPlayoutBuilderTests +{ + private readonly ILogger _logger; + + public BlockPlayoutBuilderTests() + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .Destructure.UsingAttributes() + .CreateLogger(); + + ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger); + + _logger = loggerFactory.CreateLogger(); + } + + [TestFixture] + public class Build : BlockPlayoutBuilderTests + { + [Test] + [CancelAfter(10_000)] + public async Task Should_Start_At_Beginning_Of_Current_Block(CancellationToken cancellationToken) + { + var collection = new SmartCollection + { + Id = 1, + Query = "asdf" + }; + + var block = new Block + { + Id = 1, + Name = "Test Block", + Minutes = 30, + Items = + [ + new BlockItem + { + Id = 1, + CollectionType = CollectionType.SmartCollection, + PlaybackOrder = PlaybackOrder.Chronological, + Index = 1, + SmartCollection = collection, + SmartCollectionId = collection.Id + } + ], + StopScheduling = BlockStopScheduling.AfterDurationEnd + }; + + var template = new Template + { + Id = 1, + Items = [] + }; + + var templateItem = new TemplateItem + { + Block = block, + BlockId = block.Id, + StartTime = TimeSpan.FromHours(9), + Template = template, + TemplateId = template.Id + }; + + template.Items.Add(templateItem); + + var playoutTemplate = new PlayoutTemplate + { + Id = 1, + Index = 1, + Template = template, + TemplateId = template.Id, + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear() + }; + + var playout = new Playout + { + Id = 1, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, + Templates = + [ + playoutTemplate + ], + Items = [], + PlayoutHistory = [] + }; + + var now = new DateTimeOffset(2024, 1, 10, 9, 15, 0, TimeSpan.FromHours(-6)); + + var mediaItems = new List + { + new Movie + { + Id = 1, + MovieMetadata = [new MovieMetadata { ReleaseDate = DateTime.Today }], + MediaVersions = + [ + new MediaVersion + { + Duration = TimeSpan.FromMinutes(25), + MediaFiles = [new MediaFile { Path = "/fake/path/1" }] + } + ] + } + }; + + var collectionRepo = new FakeMediaCollectionRepository( + Map((collection.Id, mediaItems)) + ); + + IConfigElementRepository configRepo = Substitute.For(); + configRepo + .GetValue(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any()) + .Returns(Some(1)); + + var builder = new BlockPlayoutBuilder( + configRepo, + collectionRepo, + Substitute.For(), + Substitute.For(), + Substitute.For(), + _logger); + + var referenceData = new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + playout.Templates.ToList(), + null, + [], + [], + TimeSpan.Zero); + + PlayoutBuildResult result = await builder.Build( + now, + playout, + referenceData, + PlayoutBuildMode.Reset, + cancellationToken); + + // this test only cares about "today" + result.AddedItems.RemoveAll(i => i.StartOffset.Date > now.Date); + + result.AddedItems.Count.ShouldBe(1); + result.AddedItems[0].StartOffset.TimeOfDay.ShouldBe(TimeSpan.FromHours(9)); + } + + [Test] + [CancelAfter(10_000)] + public async Task Should_Discard_Item_That_Will_Never_Fit(CancellationToken cancellationToken) + { + var collection = new SmartCollection + { + Id = 1, + Query = "asdf" + }; + + var block = new Block + { + Id = 1, + Name = "Test Block", + Minutes = 30, + Items = + [ + new BlockItem + { + Id = 1, + CollectionType = CollectionType.SmartCollection, + PlaybackOrder = PlaybackOrder.Chronological, + Index = 1, + SmartCollection = collection, + SmartCollectionId = collection.Id + } + ], + StopScheduling = BlockStopScheduling.BeforeDurationEnd + }; + + var template = new Template + { + Id = 1, + Items = [] + }; + + var templateItem = new TemplateItem + { + Block = block, + BlockId = block.Id, + StartTime = TimeSpan.FromHours(9), + Template = template, + TemplateId = template.Id + }; + + template.Items.Add(templateItem); + + var playoutTemplate = new PlayoutTemplate + { + Id = 1, + Index = 1, + Template = template, + TemplateId = template.Id, + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear() + }; + + var playout = new Playout + { + Id = 1, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, + Templates = + [ + playoutTemplate + ], + Items = [], + PlayoutHistory = [] + }; + + var now = new DateTimeOffset(2024, 1, 10, 9, 15, 0, TimeSpan.FromHours(-6)); + + var mediaItems = new List + { + new Movie + { + Id = 1, + MovieMetadata = [new MovieMetadata { ReleaseDate = DateTime.Today }], + MediaVersions = + [ + new MediaVersion + { + Duration = TimeSpan.FromHours(1), + MediaFiles = [new MediaFile { Path = "/fake/path/1" }] + } + ] + }, + new Movie + { + Id = 2, + MovieMetadata = [new MovieMetadata { ReleaseDate = DateTime.Today.AddDays(1) }], + MediaVersions = + [ + new MediaVersion + { + Duration = TimeSpan.FromMinutes(25), + MediaFiles = [new MediaFile { Path = "/fake/path/2" }] + } + ] + } + }; + + var collectionRepo = new FakeMediaCollectionRepository( + Map((collection.Id, mediaItems)) + ); + + IConfigElementRepository configRepo = Substitute.For(); + configRepo + .GetValue(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any()) + .Returns(Some(1)); + + var builder = new BlockPlayoutBuilder( + configRepo, + collectionRepo, + Substitute.For(), + Substitute.For(), + Substitute.For(), + _logger); + + var referenceData = new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + playout.Templates.ToList(), + null, + [], + [], + TimeSpan.Zero); + + PlayoutBuildResult result = await builder.Build( + now, + playout, + referenceData, + PlayoutBuildMode.Reset, + cancellationToken); + + // this test only cares about "today" + result.AddedItems.RemoveAll(i => i.StartOffset.Date > now.Date); + + result.AddedItems.Count.ShouldBe(1); + result.AddedItems[0].StartOffset.TimeOfDay.ShouldBe(TimeSpan.FromHours(9)); + } + + [Test] + [CancelAfter(10_000)] + public async Task Should_Discard_Collection_That_Will_Never_Fit(CancellationToken cancellationToken) + { + var collection = new SmartCollection + { + Id = 1, + Query = "asdf" + }; + + var collection2 = new SmartCollection + { + Id = 2, + Query = "asdf2" + }; + + var block = new Block + { + Id = 1, + Name = "Test Block", + Minutes = 30, + Items = + [ + new BlockItem + { + Id = 1, + CollectionType = CollectionType.SmartCollection, + PlaybackOrder = PlaybackOrder.Chronological, + Index = 1, + SmartCollection = collection, + SmartCollectionId = collection.Id + }, + new BlockItem + { + Id = 2, + CollectionType = CollectionType.SmartCollection, + PlaybackOrder = PlaybackOrder.Chronological, + Index = 2, + SmartCollection = collection2, + SmartCollectionId = collection2.Id + } + ], + StopScheduling = BlockStopScheduling.BeforeDurationEnd + }; + + var template = new Template + { + Id = 1, + Items = [] + }; + + var templateItem = new TemplateItem + { + Block = block, + BlockId = block.Id, + StartTime = TimeSpan.FromHours(9), + Template = template, + TemplateId = template.Id + }; + + template.Items.Add(templateItem); + + var playoutTemplate = new PlayoutTemplate + { + Id = 1, + Index = 1, + Template = template, + TemplateId = template.Id, + DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(), + DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(), + MonthsOfYear = PlayoutTemplate.AllMonthsOfYear() + }; + + var playout = new Playout + { + Id = 1, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, + Templates = + [ + playoutTemplate + ], + Items = [], + PlayoutHistory = [] + }; + + var now = new DateTimeOffset(2024, 1, 10, 9, 15, 0, TimeSpan.FromHours(-6)); + + var mediaItems = new List + { + new Movie + { + Id = 1, + MovieMetadata = [new MovieMetadata { ReleaseDate = DateTime.Today }], + MediaVersions = + [ + new MediaVersion + { + Duration = TimeSpan.FromHours(1), + MediaFiles = [new MediaFile { Path = "/fake/path/1" }] + } + ] + } + }; + + var mediaItems2 = new List + { + new Movie + { + Id = 2, + MovieMetadata = [new MovieMetadata { ReleaseDate = DateTime.Today.AddDays(1) }], + MediaVersions = + [ + new MediaVersion + { + Duration = TimeSpan.FromMinutes(25), + MediaFiles = [new MediaFile { Path = "/fake/path/2" }] + } + ] + } + }; + + var collectionRepo = new FakeMediaCollectionRepository( + new Map>( + [ + (collection.Id, mediaItems), + (collection2.Id, mediaItems2) + ]) + ); + + IConfigElementRepository configRepo = Substitute.For(); + configRepo + .GetValue(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any()) + .Returns(Some(1)); + + var builder = new BlockPlayoutBuilder( + configRepo, + collectionRepo, + Substitute.For(), + Substitute.For(), + Substitute.For(), + _logger); + + var referenceData = new PlayoutReferenceData( + playout.Channel, + Option.None, + [], + playout.Templates.ToList(), + null, + [], + [], + TimeSpan.Zero); + + PlayoutBuildResult result = await builder.Build( + now, + playout, + referenceData, + PlayoutBuildMode.Reset, + cancellationToken); + + // this test only cares about "today" + result.AddedItems.RemoveAll(i => i.StartOffset.Date > now.Date); + + result.AddedItems.Count.ShouldBe(1); + result.AddedItems[0].StartOffset.TimeOfDay.ShouldBe(TimeSpan.FromHours(9)); + } + } +} diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs index 614b5b244..eee39beb8 100644 --- a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs @@ -173,99 +173,138 @@ public class BlockPlayoutBuilder( collectionMediaItems); var pastTime = false; + var done = false; - foreach (MediaItem mediaItem in enumerator.Current) + while (!done && !pastTime) { - logger.LogDebug( - "current item: {Id} / {Title}", - mediaItem.Id, - mediaItem is Episode e ? GetTitle(e) : string.Empty); - - TimeSpan itemDuration = DurationForMediaItem(mediaItem); - - var collectionKey = CollectionKey.ForBlockItem(blockItem); - - // create a playout item - var playoutItem = new PlayoutItem - { - PlayoutId = playout.Id, - MediaItemId = mediaItem.Id, - Start = currentTime.UtcDateTime, - Finish = currentTime.UtcDateTime + itemDuration, - InPoint = TimeSpan.Zero, - OutPoint = itemDuration, - FillerKind = blockItem.IncludeInProgramGuide ? FillerKind.None : FillerKind.GuideMode, - DisableWatermarks = blockItem.DisableWatermarks, - //CustomTitle = scheduleItem.CustomTitle, - //WatermarkId = scheduleItem.WatermarkId, - //PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, - //PreferredAudioTitle = scheduleItem.PreferredAudioTitle, - //PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, - //SubtitleMode = scheduleItem.SubtitleMode - GuideGroup = effectiveBlock.TemplateItemId, - GuideStart = effectiveBlock.Start.UtcDateTime, - GuideFinish = blockFinish.UtcDateTime, - BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey), - CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings), - CollectionEtag = collectionEtags[collectionKey], - PlayoutItemWatermarks = [], - PlayoutItemGraphicsElements = [] - }; - - foreach (BlockItemWatermark blockItemWatermark in blockItem.BlockItemWatermarks ?? []) + foreach (MediaItem mediaItem in enumerator.Current) { - playoutItem.PlayoutItemWatermarks.Add( - new PlayoutItemWatermark + logger.LogDebug( + "current item: {Id} / {Title}", + mediaItem.Id, + PlayoutBuilder.DisplayTitle(mediaItem)); + + TimeSpan itemDuration = DurationForMediaItem(mediaItem); + + // item will never fit in block + var blockDuration = TimeSpan.FromMinutes(effectiveBlock.Block.Minutes); + if (effectiveBlock.Block.StopScheduling is BlockStopScheduling.BeforeDurationEnd && + itemDuration > blockDuration) + { + foreach (TimeSpan minimumDuration in enumerator.MinimumDuration) { - PlayoutItem = playoutItem, - WatermarkId = blockItemWatermark.WatermarkId - }); - } - - foreach (BlockItemGraphicsElement blockItemGraphicsElement in blockItem.BlockItemGraphicsElements ?? - []) - { - playoutItem.PlayoutItemGraphicsElements.Add( - new PlayoutItemGraphicsElement + if (minimumDuration > blockDuration) + { + Logger.LogError( + "Collection with minimum duration {Duration:hh\\:mm\\:ss} will never fit in block with duration {BlockDuration:hh\\:mm\\:ss}; skipping this block item!", + minimumDuration, + blockDuration); + + done = true; + } + } + + if (done) { - PlayoutItem = playoutItem, - GraphicsElementId = blockItemGraphicsElement.GraphicsElementId - }); - } - - if (effectiveBlock.Block.StopScheduling is BlockStopScheduling.BeforeDurationEnd - && playoutItem.FinishOffset > blockFinish) - { - logger.LogDebug( - "Current time {Time} for block {Block} would go beyond block finish {Finish}; will not schedule more items", - currentTime, - effectiveBlock.Block.Name, - blockFinish); - - pastTime = true; - break; + break; + } + + Logger.LogWarning( + "Skipping playout item {Title} with duration {Duration:hh\\:mm\\:ss} that will never fit in block with duration {BlockDuration:hh\\:mm\\:ss}", + PlayoutBuilder.DisplayTitle(mediaItem), + itemDuration, + blockDuration); + + enumerator.MoveNext(Option.None); + continue; + } + + var collectionKey = CollectionKey.ForBlockItem(blockItem); + + // create a playout item + var playoutItem = new PlayoutItem + { + PlayoutId = playout.Id, + MediaItemId = mediaItem.Id, + Start = currentTime.UtcDateTime, + Finish = currentTime.UtcDateTime + itemDuration, + InPoint = TimeSpan.Zero, + OutPoint = itemDuration, + FillerKind = blockItem.IncludeInProgramGuide ? FillerKind.None : FillerKind.GuideMode, + DisableWatermarks = blockItem.DisableWatermarks, + //CustomTitle = scheduleItem.CustomTitle, + //WatermarkId = scheduleItem.WatermarkId, + //PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode, + //PreferredAudioTitle = scheduleItem.PreferredAudioTitle, + //PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode, + //SubtitleMode = scheduleItem.SubtitleMode + GuideGroup = effectiveBlock.TemplateItemId, + GuideStart = effectiveBlock.Start.UtcDateTime, + GuideFinish = blockFinish.UtcDateTime, + BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey), + CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings), + CollectionEtag = collectionEtags[collectionKey], + PlayoutItemWatermarks = [], + PlayoutItemGraphicsElements = [] + }; + + foreach (BlockItemWatermark blockItemWatermark in blockItem.BlockItemWatermarks ?? []) + { + playoutItem.PlayoutItemWatermarks.Add( + new PlayoutItemWatermark + { + PlayoutItem = playoutItem, + WatermarkId = blockItemWatermark.WatermarkId + }); + } + + foreach (BlockItemGraphicsElement blockItemGraphicsElement in blockItem + .BlockItemGraphicsElements ?? + []) + { + playoutItem.PlayoutItemGraphicsElements.Add( + new PlayoutItemGraphicsElement + { + PlayoutItem = playoutItem, + GraphicsElementId = blockItemGraphicsElement.GraphicsElementId + }); + } + + if (effectiveBlock.Block.StopScheduling is BlockStopScheduling.BeforeDurationEnd + && playoutItem.FinishOffset > blockFinish) + { + logger.LogDebug( + "Current time {Time} for block {Block} would go beyond block finish {Finish}; will not schedule more items", + currentTime, + effectiveBlock.Block.Name, + blockFinish); + + pastTime = true; + break; + } + + result.AddedItems.Add(playoutItem); + + // create a playout history record + var nextHistory = new PlayoutHistory + { + PlayoutId = playout.Id, + BlockId = blockItem.BlockId, + PlaybackOrder = blockItem.PlaybackOrder, + Index = enumerator.State.Index, + When = currentTime.UtcDateTime, + Finish = playoutItem.FinishOffset.UtcDateTime, + Key = historyKey, + Details = HistoryDetails.ForMediaItem(mediaItem) + }; + + //logger.LogDebug("Adding history item: {When}: {History}", nextHistory.When, nextHistory.Details); + result.AddedHistory.Add(nextHistory); + + currentTime += itemDuration; + enumerator.MoveNext(playoutItem.StartOffset); + done = true; } - - result.AddedItems.Add(playoutItem); - - // create a playout history record - var nextHistory = new PlayoutHistory - { - PlayoutId = playout.Id, - BlockId = blockItem.BlockId, - PlaybackOrder = blockItem.PlaybackOrder, - Index = enumerator.State.Index, - When = currentTime.UtcDateTime, - Finish = playoutItem.FinishOffset.UtcDateTime, - Key = historyKey, - Details = HistoryDetails.ForMediaItem(mediaItem) - }; - - //logger.LogDebug("Adding history item: {When}: {History}", nextHistory.When, nextHistory.Details); - result.AddedHistory.Add(nextHistory); - - currentTime += itemDuration; - enumerator.MoveNext(playoutItem.StartOffset); } if (pastTime) @@ -336,23 +375,6 @@ public class BlockPlayoutBuilder( return enumerator; } - private static string GetTitle(Episode e) - { - string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() - .Map(sm => $"{sm.Title} - ").IfNone(string.Empty); - var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList(); - var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList(); - if (episodeNumbers.Count == 0 || episodeTitles.Count == 0) - { - return "[unknown episode]"; - } - - var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}"; - var titlesString = $"{string.Join('/', episodeTitles)}"; - - return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}"; - } - private static PlayoutBuildResult CleanUpHistory( PlayoutReferenceData referenceData, DateTimeOffset start,