Browse Source

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
pull/2434/head
Jason Dove 4 months ago committed by GitHub
parent
commit
f26e48c063
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 476
      ErsatzTV.Core.Tests/Scheduling/BlockScheduling/BlockPlayoutBuilderTests.cs
  3. 230
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs

2
CHANGELOG.md

@ -44,6 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

476
ErsatzTV.Core.Tests/Scheduling/BlockScheduling/BlockPlayoutBuilderTests.cs

@ -0,0 +1,476 @@ @@ -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<BlockPlayoutBuilder> _logger;
public BlockPlayoutBuilderTests()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.Destructure.UsingAttributes()
.CreateLogger();
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
_logger = loggerFactory.CreateLogger<BlockPlayoutBuilder>();
}
[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<MediaItem>
{
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<IConfigElementRepository>();
configRepo
.GetValue<int>(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any<CancellationToken>())
.Returns(Some(1));
var builder = new BlockPlayoutBuilder(
configRepo,
collectionRepo,
Substitute.For<ITelevisionRepository>(),
Substitute.For<IArtistRepository>(),
Substitute.For<ICollectionEtag>(),
_logger);
var referenceData = new PlayoutReferenceData(
playout.Channel,
Option<Deco>.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<MediaItem>
{
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<IConfigElementRepository>();
configRepo
.GetValue<int>(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any<CancellationToken>())
.Returns(Some(1));
var builder = new BlockPlayoutBuilder(
configRepo,
collectionRepo,
Substitute.For<ITelevisionRepository>(),
Substitute.For<IArtistRepository>(),
Substitute.For<ICollectionEtag>(),
_logger);
var referenceData = new PlayoutReferenceData(
playout.Channel,
Option<Deco>.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<MediaItem>
{
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<MediaItem>
{
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<int, List<MediaItem>>(
[
(collection.Id, mediaItems),
(collection2.Id, mediaItems2)
])
);
IConfigElementRepository configRepo = Substitute.For<IConfigElementRepository>();
configRepo
.GetValue<int>(Arg.Is(ConfigElementKey.PlayoutDaysToBuild), Arg.Any<CancellationToken>())
.Returns(Some(1));
var builder = new BlockPlayoutBuilder(
configRepo,
collectionRepo,
Substitute.For<ITelevisionRepository>(),
Substitute.For<IArtistRepository>(),
Substitute.For<ICollectionEtag>(),
_logger);
var referenceData = new PlayoutReferenceData(
playout.Channel,
Option<Deco>.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));
}
}
}

230
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs

@ -173,99 +173,138 @@ public class BlockPlayoutBuilder( @@ -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<DateTimeOffset>.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( @@ -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,

Loading…
Cancel
Save