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
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>
488 lines
20 KiB
C#
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);
|
|
}
|
|
}
|