Files
ersatztv/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.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

207 lines
8.2 KiB
C#

using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Scheduling.YamlScheduling;
public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepository, ILogger logger)
{
private readonly Dictionary<string, IMediaCollectionEnumerator> _enumerators = new();
private readonly Dictionary<string, List<MediaItem>> _mediaItems = new();
private readonly Dictionary<PlaylistKey, List<MediaItem>> _playlistMediaItems = new();
public System.Collections.Generic.HashSet<string> MissingContentKeys { get; } = [];
public List<MediaItem> MediaItemsForContent(string contentKey) =>
_mediaItems.TryGetValue(contentKey, out List<MediaItem> items) ? items : [];
public List<MediaItem> PlaylistMediaItemsForContent(string contentKey, CollectionKey collectionKey) =>
_playlistMediaItems.TryGetValue(new PlaylistKey(contentKey, collectionKey), out List<MediaItem> items)
? items
: [];
public async Task<Option<IMediaCollectionEnumerator>> GetCachedEnumeratorForContent(
YamlPlayoutContext context,
string contentKey,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(contentKey))
{
return Option<IMediaCollectionEnumerator>.None;
}
if (!_enumerators.TryGetValue(contentKey, out IMediaCollectionEnumerator enumerator))
{
Option<IMediaCollectionEnumerator> maybeEnumerator =
await GetEnumeratorForContent(context, contentKey, cancellationToken);
if (maybeEnumerator.IsNone)
{
return Option<IMediaCollectionEnumerator>.None;
}
foreach (IMediaCollectionEnumerator e in maybeEnumerator)
{
enumerator = e;
_enumerators.Add(contentKey, enumerator);
}
}
return Some(enumerator);
}
private async Task<Option<IMediaCollectionEnumerator>> GetEnumeratorForContent(
YamlPlayoutContext context,
string contentKey,
CancellationToken cancellationToken)
{
int index = context.Definition.Content.FindIndex(c => c.Key == contentKey);
if (index < 0)
{
return Option<IMediaCollectionEnumerator>.None;
}
List<MediaItem> items = [];
YamlPlayoutContentItem content = context.Definition.Content[index];
switch (content)
{
case YamlPlayoutContentSearchItem search:
items = await mediaCollectionRepository.GetSmartCollectionItems(search.Query, string.Empty, cancellationToken);
break;
case YamlPlayoutContentShowItem show:
items = await mediaCollectionRepository.GetShowItemsByShowGuids(
show.Guids.Map(g => $"{g.Source}://{g.Value}").ToList());
break;
case YamlPlayoutContentCollectionItem collection:
items = await mediaCollectionRepository.GetCollectionItemsByName(
collection.Collection,
cancellationToken);
break;
case YamlPlayoutContentSmartCollectionItem smartCollection:
items = await mediaCollectionRepository.GetSmartCollectionItemsByName(
smartCollection.SmartCollection,
cancellationToken);
break;
case YamlPlayoutContentMultiCollectionItem multiCollection:
items = await mediaCollectionRepository.GetMultiCollectionItemsByName(
multiCollection.MultiCollection,
cancellationToken);
break;
// playlist is handled later
}
_mediaItems[content.Key] = items;
var state = new CollectionEnumeratorState { Seed = context.Playout.Seed + index, Index = 0 };
// marathon is a special case that needs to be handled on its own
if (content is YamlPlayoutContentMarathonItem marathon)
{
var helper = new MarathonHelper(mediaCollectionRepository);
var guids = new Dictionary<string, List<string>>();
foreach (var guid in marathon.Guids)
{
if (!guids.TryGetValue(guid.Source, out List<string> value))
{
value = [];
guids.Add(guid.Source, value);
}
value.Add(guid.Value);
}
if (!Enum.TryParse(marathon.ItemOrder, true, out PlaybackOrder itemPlaybackOrder))
{
itemPlaybackOrder = PlaybackOrder.Shuffle;
}
Option<PlaylistContentResult> maybeResult = await helper.GetEnumerator(
guids,
marathon.Searches,
marathon.GroupBy,
marathon.ShuffleGroups,
itemPlaybackOrder,
marathon.PlayAllItems,
state,
cancellationToken);
foreach (PlaylistContentResult result in maybeResult)
{
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in result.Content)
{
_playlistMediaItems.Add(new PlaylistKey(contentKey, collectionKey), mediaItems);
}
return result.PlaylistEnumerator;
}
}
// playlist is a special case that needs to be handled on its own
if (content is YamlPlayoutContentPlaylistItem playlist)
{
if (!string.IsNullOrWhiteSpace(playlist.Order) && !string.Equals(
playlist.Order,
"none",
StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning(
"Ignoring playback order {Order} for playlist {Playlist}",
playlist.Order,
playlist.Playlist);
}
Dictionary<PlaylistItem, List<MediaItem>> itemMap =
await mediaCollectionRepository.GetPlaylistItemMap(
playlist.PlaylistGroup,
playlist.Playlist,
cancellationToken);
foreach ((PlaylistItem playlistItem, List<MediaItem> mediaItems) in itemMap)
{
_playlistMediaItems.Add(
new PlaylistKey(contentKey, CollectionKey.ForPlaylistItem(playlistItem)),
mediaItems);
}
return await PlaylistEnumerator.Create(
mediaCollectionRepository,
itemMap,
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
cancellationToken,
Optional(logger));
}
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
switch (parsedOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
case PlaybackOrder.Shuffle:
bool keepMultiPartEpisodesTogether = content.MultiPart;
List<GroupedMediaItem> groupedMediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
}
// this path schedules nothing for the content, which is indistinguishable from "no items" downstream.
// Orders are addressed by name here, so any order the enum knows parses fine and then lands here --
// say so, rather than leaving an empty schedule to be explained (#70).
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
parsedOrder);
return Option<IMediaCollectionEnumerator>.None;
}
private record PlaylistKey(string ContentKey, CollectionKey CollectionKey);
}