---
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).
