--- key: sched.shuffle-source-builder title: 2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380) status: active since: '2026-07-17' supersedes: none superseded-by: none rule: 'Shuffle-source construction moves to a static, DI-free `ShuffleSourceBuilder` (a shared seam, not a service) so Classic and Playlist stop cross-engine reaching into `PlayoutBuilder` statics; a unified Classic+Playlist enumerator factory is explicitly rejected as a god-factory. Block/Scripted/YAML duplication is left alone, deferred to a follow-up gated on #381.' signals: 'ShuffleSourceBuilder, PlaybackOrder, cross-engine reach-in, god-factory rejection · paths: `ErsatzTV.Core/Scheduling/ShuffleSourceBuilder.cs`, `PlayoutBuilder`, `PlaylistEnumerator` · issues: #380, #163, #176, #70, #381' mechanics: '`ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle`/`GetCollectionItemsForShuffleInOrder`; `classic-shuffle` golden in `PlayoutBuildGoldenTests`' --- `PlaybackOrder` is turned into an enumerator at five independent sites (one per schedule kind, dispatched by `BuildPlayoutHandler` on `ScheduleKind`): Classic (`PlayoutBuilder`), Block (`BlockPlayoutBuilder`, `PlayoutHistory` rotation), Scripted (`SchedulingEngine.EnumeratorForContent`), Sequential/YAML (`EnumeratorCache`), and the Playlist leaf (`PlaylistEnumerator.Create`, consumed by the others). These are **not** accidental duplication — they map the same enum to different enumerator *families* keyed on different state models (Block's history cursor vs the stateless-index `CollectionEnumeratorState` enumerators). The real smell was one **cross-engine reach-in**: `PlaylistEnumerator` called `PlayoutBuilder.GetGroupedMediaItemsForShuffle` / `GetCollectionItemsForShuffleInOrder` as **statics** — one engine reaching into another engine's class. - **Fix (a) shipped, not (b).** The two shuffle-source helpers moved verbatim to a new `public static class ShuffleSourceBuilder` in `ErsatzTV.Core/Scheduling` (sibling to the also-static `MultiCollectionGrouper` / `MultiPartEpisodeGrouper`; dependencies passed as parameters, **not** a DI service — `PlaylistEnumerator.Create` is itself a static factory). Both Classic and Playlist now consume it, so the reach-in is gone and shuffle-source construction lives in one directly-unit-tested place. - **One intentional signature change.** `GetGroupedMediaItemsForShuffle` now takes `bool keepMultiPartEpisodesTogether, bool treatCollectionsAsShows` instead of a `ProgramSchedule` (verified those are the only two properties it read). This deletes `PlaylistEnumerator`'s fake `new ProgramSchedule { KeepMultiPartEpisodesTogether = false }` (its TODO becomes an honest `false, false`) and gives future callers with no `ProgramSchedule` (#176 PseudoTV, #70 distribution) a schedule-entity-free entry point. Behavior is unchanged. - **Rejected: a unified Classic+Playlist enumerator factory (b).** That would be the god-factory the issue forbids — three concrete hazards: Classic's `Marathon` arm *produces* a `PlaylistEnumerator` (a factory consumed by `PlaylistEnumerator.Create` that also builds one is a dependency cycle); Playlist hardcodes `KeepMultiPartEpisodesTogether=false`/`randomStartPoint=false` where Classic reads schedule flags (a merge silently changes behavior); and Classic's `CustomOrder`/`Rerun`/`MultiEpisodeShuffle`-template arms gate on instance deps Playlist doesn't have. Engine separation is deliberately preserved. - **Block / Scripted / YAML left alone.** Block is a distinct family; the Scripted≡YAML construction duplication (`EnumeratorForContent` ≡ `EnumeratorCache.GetEnumerator`, both using the *different* `BlockPlayoutShuffledMediaCollectionEnumerator` for shuffle) is a real but separate cleanup — deferred to a follow-up gated on #381 (those paths have no golden coverage yet). - **Net first, because #163's was too narrow.** `PlayoutBuildGoldenTests` only pinned `PlaybackOrder.Chronological`; the moved shuffle paths (incl. the entire Playlist reach-in) were uncovered, so "goldens green" would have been a false signal. The PR adds a `classic-shuffle` golden (pinned `Playout.Seed` + `Continue` build, since `Reset` randomizes the seed) and a `PlaylistEnumerator.Create` reach-in characterization (Shuffle + ShuffleInOrder items) **before** the move, plus direct `ShuffleSourceBuilder` unit tests for the branches goldens don't reach (multi-collection vs fake-multi-collection lookup; multi-part grouping on/off).