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>
135 lines
6.5 KiB
C#
135 lines
6.5 KiB
C#
using ErsatzTV.Core.Domain;
|
|
using OrderSet = System.Collections.Generic.HashSet<ErsatzTV.Core.Domain.PlaybackOrder>;
|
|
|
|
namespace ErsatzTV.Core.Scheduling;
|
|
|
|
/// <summary>
|
|
/// The scheduling engine families that turn a <see cref="PlaybackOrder" /> into an enumerator, and which
|
|
/// orders each one actually handles. This is the single declared support matrix for #403.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each engine keeps BOTH a <c>Supported</c> and an <c>Unsupported</c> set, maintained by hand ON PURPOSE:
|
|
/// <c>PlaybackOrderSupportTests</c> asserts the two sets partition every <see cref="PlaybackOrder" /> value
|
|
/// (union is total, intersection empty), so adding a new order fails that test until it is consciously
|
|
/// classified here. Deriving <c>Unsupported</c> as "everything not supported" would let a new order fall
|
|
/// through silently — the very defect #403 exists to kill — so it is spelled out instead.
|
|
///
|
|
/// Membership here mirrors the executable dispatch in each builder (<c>PlayoutBuilder</c>,
|
|
/// <c>PlaylistEnumerator</c>, <c>BlockPlayoutBuilder</c>, <c>EnumeratorCache</c> [YAML],
|
|
/// <c>SchedulingEngine.EnumeratorForContent</c> [Scripted]); the builders remain the real logic. Only
|
|
/// <see cref="SchedulingEngineKind.Block" /> consumes this table at runtime (its allow-list), so the table
|
|
/// is not merely test scaffolding. When you add a case to one of those switches, update the matching set
|
|
/// here.
|
|
/// </remarks>
|
|
public static class PlaybackOrderSupport
|
|
{
|
|
private sealed record EngineSupport(
|
|
IReadOnlySet<PlaybackOrder> Supported,
|
|
IReadOnlySet<PlaybackOrder> Unsupported);
|
|
|
|
private static readonly IReadOnlyDictionary<SchedulingEngineKind, EngineSupport> Matrix =
|
|
new Dictionary<SchedulingEngineKind, EngineSupport>
|
|
{
|
|
// PlayoutBuilder.GetMediaCollectionEnumerator switch; default arm falls back to Random (now loud).
|
|
[SchedulingEngineKind.Classic] = new EngineSupport(
|
|
new OrderSet
|
|
{
|
|
PlaybackOrder.Chronological,
|
|
PlaybackOrder.SeasonEpisode,
|
|
PlaybackOrder.Random,
|
|
PlaybackOrder.Shuffle,
|
|
PlaybackOrder.ShuffleInOrder,
|
|
PlaybackOrder.MultiEpisodeShuffle,
|
|
PlaybackOrder.Marathon,
|
|
PlaybackOrder.WeightedShuffle
|
|
},
|
|
new OrderSet { PlaybackOrder.None, PlaybackOrder.RandomRotation }),
|
|
|
|
// PlaylistEnumerator.Create switch; no default arm meant a null enumerator -> silent drop (now loud).
|
|
[SchedulingEngineKind.Playlist] = new EngineSupport(
|
|
new OrderSet
|
|
{
|
|
PlaybackOrder.Chronological,
|
|
PlaybackOrder.SeasonEpisode,
|
|
PlaybackOrder.Random,
|
|
PlaybackOrder.Shuffle,
|
|
PlaybackOrder.ShuffleInOrder,
|
|
PlaybackOrder.MultiEpisodeShuffle
|
|
},
|
|
new OrderSet
|
|
{
|
|
PlaybackOrder.None,
|
|
PlaybackOrder.RandomRotation,
|
|
PlaybackOrder.Marathon,
|
|
PlaybackOrder.WeightedShuffle
|
|
}),
|
|
|
|
// BlockPlayoutBuilder allow-list (this very set) + the GetEnumerator switch.
|
|
[SchedulingEngineKind.Block] = new EngineSupport(
|
|
new OrderSet
|
|
{
|
|
PlaybackOrder.Chronological,
|
|
PlaybackOrder.SeasonEpisode,
|
|
PlaybackOrder.Shuffle,
|
|
PlaybackOrder.Random,
|
|
PlaybackOrder.RandomRotation
|
|
},
|
|
new OrderSet
|
|
{
|
|
PlaybackOrder.None,
|
|
PlaybackOrder.ShuffleInOrder,
|
|
PlaybackOrder.MultiEpisodeShuffle,
|
|
PlaybackOrder.Marathon,
|
|
PlaybackOrder.WeightedShuffle
|
|
}),
|
|
|
|
// EnumeratorCache (YAML, non-playlist path); unsupported orders LogWarning + None.
|
|
[SchedulingEngineKind.Yaml] = new EngineSupport(
|
|
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
|
|
new OrderSet
|
|
{
|
|
PlaybackOrder.None,
|
|
PlaybackOrder.Random,
|
|
PlaybackOrder.SeasonEpisode,
|
|
PlaybackOrder.ShuffleInOrder,
|
|
PlaybackOrder.MultiEpisodeShuffle,
|
|
PlaybackOrder.RandomRotation,
|
|
PlaybackOrder.Marathon,
|
|
PlaybackOrder.WeightedShuffle
|
|
}),
|
|
|
|
// SchedulingEngine.EnumeratorForContent (Scripted); unsupported orders LogWarning + None.
|
|
[SchedulingEngineKind.Scripted] = new EngineSupport(
|
|
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
|
|
new OrderSet
|
|
{
|
|
PlaybackOrder.None,
|
|
PlaybackOrder.Random,
|
|
PlaybackOrder.SeasonEpisode,
|
|
PlaybackOrder.ShuffleInOrder,
|
|
PlaybackOrder.MultiEpisodeShuffle,
|
|
PlaybackOrder.RandomRotation,
|
|
PlaybackOrder.Marathon,
|
|
PlaybackOrder.WeightedShuffle
|
|
})
|
|
};
|
|
|
|
public static IReadOnlyCollection<SchedulingEngineKind> Engines => Matrix.Keys.ToList();
|
|
|
|
/// <summary>The orders <paramref name="engine" /> can turn into an enumerator.</summary>
|
|
public static IReadOnlySet<PlaybackOrder> SupportedBy(SchedulingEngineKind engine) => Matrix[engine].Supported;
|
|
|
|
/// <summary>The orders explicitly known NOT to be handled by <paramref name="engine" />.</summary>
|
|
public static IReadOnlySet<PlaybackOrder> UnsupportedBy(SchedulingEngineKind engine) => Matrix[engine].Unsupported;
|
|
|
|
public static bool IsSupported(SchedulingEngineKind engine, PlaybackOrder order) =>
|
|
Matrix[engine].Supported.Contains(order);
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="order" /> appears in either set for <paramref name="engine" />. A new enum
|
|
/// value is classified in neither until a human adds it — which is what the tripwire test asserts.
|
|
/// </summary>
|
|
public static bool IsClassified(SchedulingEngineKind engine, PlaybackOrder order) =>
|
|
Matrix[engine].Supported.Contains(order) || Matrix[engine].Unsupported.Contains(order);
|
|
}
|