Files
ersatztv/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs
T
timothyandClaude Opus 4.8 0f34c86afa
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 37m32s
feat(403): make unsupported PlaybackOrder loud at build time + tripwire
Adding a new PlaybackOrder was unsafe by construction: three build-time
dispatch sites turned an unknown value into an enumerator silently.
Classic substituted RandomizedMediaCollectionEnumerator (the // TODO
default arm), PlaylistEnumerator had no default arm so the item was
dropped, and BlockPlayoutBuilder's allow-list continue skipped it.
(#70 already made YAML/Scripted log a warning and MultiCollectionGroup
throws.)

- each silent site now logs a Warning naming the order + engine + the
  fallback taken; the fallback itself is preserved so a live channel
  never goes dark on one misconfigured item and scheduler goldens do
  not move.
- PlaylistEnumerator.Create gained an optional Option<ILogger> (it was
  static with no logger -- why the drop was unreportable); loggered
  callers pass it.
- BlockPlayoutBuilder gained an explicit Random arm (it previously
  reached an enumerator only via the coincidental _ => fallback) and a
  loud defensive fallback.
- new PlaybackOrderSupport matrix (per SchedulingEngineKind) + tripwire
  PlaybackOrderSupportTests: Supported ∪ Unsupported must partition the
  enum for every engine, so a new order fails the test until classified.
  BlockPlayoutBuilder consumes the matrix for its allow-list.
- write-path rejection left unchanged (#70 closed the persistence hole;
  the perimeter has been wrong three times per decisions.md); reverse
  _ => None mappings reviewed and deferred (different axis; making them
  loud would warn on legit enumerator types).

docs/decisions.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:23:21 +02:00

488 lines
20 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Map = LanguageExt.Map;
namespace ErsatzTV.Core.Scheduling.BlockScheduling;
public class BlockPlayoutBuilder(
IConfigElementRepository configElementRepository,
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
ICollectionEtag collectionEtag,
ILogger<BlockPlayoutBuilder> logger)
: IBlockPlayoutBuilder
{
private static readonly JsonSerializerSettings JsonSettings = new()
{
NullValueHandling = NullValueHandling.Ignore
};
private static readonly JsonSerializerOptions Options = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
protected virtual ILogger Logger => logger;
public virtual async Task<Either<BaseError, PlayoutBuildResult>> Build(
DateTimeOffset start,
Playout playout,
PlayoutReferenceData referenceData,
PlayoutBuildMode mode,
CancellationToken cancellationToken)
{
PlayoutBuildResult result = PlayoutBuildResult.Empty;
logger.LogDebug(
"Building block playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}",
playout.Id,
referenceData.Channel.Number,
referenceData.Channel.Name);
int daysToBuild = await GetDaysToBuild(cancellationToken);
// get blocks to schedule
List<EffectiveBlock> blocksToSchedule = EffectiveBlock.GetEffectiveBlocks(
referenceData.PlayoutTemplates,
start,
TimeZoneInfo.Local,
daysToBuild);
if (blocksToSchedule.Count == 0)
{
return result;
}
// always start at the beginning of the block
start = blocksToSchedule.Min(b => b.Start);
// get all collection items for the playout
Map<CollectionKey, List<MediaItem>> collectionMediaItems =
await GetCollectionMediaItems(blocksToSchedule, cancellationToken);
if (collectionMediaItems.Values.All(v => v.Count == 0))
{
logger.LogWarning("There are no media items to schedule");
return result;
}
Map<CollectionKey, string> collectionEtags = GetCollectionEtags(collectionMediaItems);
Dictionary<PlayoutItem, BlockKey> itemBlockKeys =
BlockPlayoutChangeDetection.GetPlayoutItemToBlockKeyMap(referenceData);
// remove items without a block key (shouldn't happen often, just upgrades)
foreach (PlayoutItem item in referenceData.ExistingItems.Where(i =>
i.FillerKind is not FillerKind.DecoDefault && !itemBlockKeys.ContainsKey(i)))
{
result.ItemsToRemove.Add(item.Id);
}
// remove old items
// importantly, this should not remove their history
result = result with { RemoveBefore = start };
(List<EffectiveBlock> updatedEffectiveBlocks, List<PlayoutItem> playoutItemsToRemove) =
BlockPlayoutChangeDetection.FindUpdatedItems(
start,
referenceData.ExistingItems,
itemBlockKeys,
blocksToSchedule,
collectionEtags);
foreach (PlayoutItem playoutItem in playoutItemsToRemove)
{
BlockPlayoutChangeDetection.RemoveItemAndHistory(referenceData, playoutItem, result);
}
var playoutItemsToRemoveIds = playoutItemsToRemove.Select(i => i.Id).ToHashSet();
var baseItems = referenceData.ExistingItems.Where(i => !playoutItemsToRemoveIds.Contains(i.Id)).ToList();
DateTimeOffset currentTime = start;
if (updatedEffectiveBlocks.Count > 0)
{
currentTime = updatedEffectiveBlocks.Min(eb => eb.Start);
}
foreach (EffectiveBlock effectiveBlock in updatedEffectiveBlocks)
{
DateTimeOffset maxExistingFinish = baseItems
.Where(i => i.Start < effectiveBlock.Start.UtcDateTime)
.Select(i => i.FinishOffset)
.DefaultIfEmpty(DateTimeOffset.MinValue)
.Max();
if (currentTime < effectiveBlock.Start)
{
currentTime = effectiveBlock.Start;
}
if (currentTime < maxExistingFinish)
{
currentTime = maxExistingFinish;
}
if (currentTime > effectiveBlock.Start)
{
logger.LogDebug(
"Will schedule block {Block} with start {Start} at {ActualStart}",
effectiveBlock.Block.Name,
effectiveBlock.Start,
currentTime);
}
else
{
logger.LogDebug(
"Will schedule block {Block} at {Start}",
effectiveBlock.Block.Name,
effectiveBlock.Start);
}
DateTimeOffset blockFinish = effectiveBlock.Start.AddMinutes(effectiveBlock.Block.Minutes);
foreach (BlockItem blockItem in effectiveBlock.Block.Items.OrderBy(i => i.Index))
{
// TODO: support other playback orders
if (!PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, blockItem.PlaybackOrder))
{
// Skipping the item silently means it never airs and nothing says why (#403).
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by block scheduling; " +
"block item {BlockItemId} will be skipped",
blockItem.PlaybackOrder,
blockItem.Id);
continue;
}
if (currentTime >= blockFinish)
{
logger.LogDebug(
"Current time {Time} for block {Block} is beyond block finish {Finish}; will stop with this block's items",
currentTime,
effectiveBlock.Block.Name,
blockFinish);
break;
}
// check for playout history for this collection
string historyKey = HistoryDetails.KeyForBlockItem(blockItem);
//logger.LogDebug("History key for block item {Item} is {Key}", blockItem.Id, historyKey);
IMediaCollectionEnumerator enumerator = GetEnumerator(
playout,
referenceData,
result,
blockItem,
currentTime,
historyKey,
collectionMediaItems);
if (enumerator.Count == 0)
{
result.Warnings.BlockItemSkippedEmptyCollection++;
continue;
}
var pastTime = false;
var done = false;
while (!done && !pastTime && !cancellationToken.IsCancellationRequested)
{
foreach (MediaItem mediaItem in enumerator.Current)
{
logger.LogDebug(
"current item: {Id} / {Title}",
mediaItem.Id,
PlayoutBuilder.DisplayTitle(mediaItem));
TimeSpan itemDuration = mediaItem.GetDurationForPlayout();
// 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)
{
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)
{
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 = [],
SchedulingContext = GetSchedulingContext(blockItem, enumerator)
};
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;
}
}
if (pastTime)
{
break;
}
}
}
result = CleanUpHistory(referenceData, start, result);
return result;
}
protected virtual async Task<int> GetDaysToBuild(CancellationToken cancellationToken) =>
await configElementRepository
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken)
.IfNoneAsync(2);
protected virtual IMediaCollectionEnumerator GetEnumerator(
Playout playout,
PlayoutReferenceData referenceData,
PlayoutBuildResult result,
BlockItem blockItem,
DateTimeOffset currentTime,
string historyKey,
Map<CollectionKey, List<MediaItem>> collectionMediaItems)
{
var collectionKey = CollectionKey.ForBlockItem(blockItem);
List<MediaItem> collectionItems = collectionMediaItems[collectionKey];
// get enumerator
IMediaCollectionEnumerator enumerator = blockItem.PlaybackOrder switch
{
PlaybackOrder.Chronological => BlockPlayoutEnumerator.Chronological(
collectionItems,
currentTime,
referenceData.PlayoutHistory.Append(result.AddedHistory).ToList(),
blockItem,
historyKey,
logger),
PlaybackOrder.SeasonEpisode => BlockPlayoutEnumerator.SeasonEpisode(
collectionItems,
currentTime,
referenceData.PlayoutHistory.Append(result.AddedHistory).ToList(),
blockItem,
historyKey,
logger),
PlaybackOrder.Shuffle => BlockPlayoutEnumerator.Shuffle(
collectionItems,
currentTime,
playout.Seed,
referenceData.PlayoutHistory.Append(result.AddedHistory).ToList(),
blockItem,
historyKey),
PlaybackOrder.RandomRotation => BlockPlayoutEnumerator.RandomRotation(
collectionItems,
currentTime,
playout.Seed,
referenceData.PlayoutHistory.Append(result.AddedHistory).ToList(),
blockItem,
historyKey),
// Random is in Block's allow-list; give it an explicit arm rather than relying on the fallback
// below (it previously worked only by coincidence -- #403).
PlaybackOrder.Random => new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 }),
_ => UnsupportedBlockOrderFallback(blockItem.PlaybackOrder, collectionItems)
};
return enumerator;
}
// Defensive: the allow-list in Build already skips unsupported orders, so this should be unreachable. If a
// supported-but-unhandled order ever lands here, be loud instead of silently rotating Random (#403).
private IMediaCollectionEnumerator UnsupportedBlockOrderFallback(
PlaybackOrder playbackOrder,
List<MediaItem> collectionItems)
{
logger.LogWarning(
"Playback order {PlaybackOrder} reached the block enumerator without a handler; falling back to Random",
playbackOrder);
return new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 });
}
private static PlayoutBuildResult CleanUpHistory(
PlayoutReferenceData referenceData,
DateTimeOffset start,
PlayoutBuildResult result)
{
IEnumerable<PlayoutHistory> allItemsToDelete = referenceData.PlayoutHistory
.GroupBy(h => h.Key)
.SelectMany(group => group
.Filter(h => h.Finish < start.UtcDateTime - referenceData.MaxPlayoutOffset)
.OrderByDescending(h => h.Finish)
.Tail());
foreach (PlayoutHistory delete in allItemsToDelete)
{
result.HistoryToRemove.Add(delete.Id);
}
return result;
}
private async Task<Map<CollectionKey, List<MediaItem>>> GetCollectionMediaItems(
List<EffectiveBlock> effectiveBlocks,
CancellationToken cancellationToken)
{
var collectionKeys = effectiveBlocks.Map(b => b.Block.Items)
.Flatten()
.DistinctBy(i => i.Id)
.Map(CollectionKey.ForBlockItem)
.Distinct()
.ToList();
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> tuples = await collectionKeys.Map(async collectionKey =>
Tuple(
collectionKey,
await MediaItemsForCollection.Collect(
mediaCollectionRepository,
televisionRepository,
artistRepository,
collectionKey,
cancellationToken))).SequenceParallel();
return Map.createRange(tuples);
}
private Map<CollectionKey, string> GetCollectionEtags(
Map<CollectionKey, List<MediaItem>> collectionMediaItems)
{
var result = new Map<CollectionKey, string>();
foreach ((CollectionKey key, List<MediaItem> items) in collectionMediaItems)
{
result = result.Add(key, collectionEtag.ForCollectionItems(items));
}
return result;
}
private static string GetSchedulingContext(BlockItem blockItem, IMediaCollectionEnumerator enumerator)
{
var context = new BlockSchedulingContext(
blockItem.BlockId,
blockItem.Id,
enumerator.SchedulingContextName,
enumerator.State.Seed,
enumerator.State.Index);
return System.Text.Json.JsonSerializer.Serialize(context, Options);
}
}