docs(70): record the WeightedShuffle design and correct the ShuffleInOrder misreading
Build CI Toolchain Image / Build & push CI image (push) Successful in 16s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 15s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 15s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 10m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled

decisions.md entry covers why one enum value rather than two or a separate
setting, why the weight lives on both multi-collection join entities with a DB
default of 1, why it can only be applied on the ShuffleInOrder-shaped path (source
identity is destroyed on the Shuffle path), why it is stateless, and why
cross-engine exposure is closed by write-path validation instead of by changing
five shipped fallbacks.

The load-bearing part is the ShuffleInOrder clarification. Its balanced-shuffle
padding reads as equalization, but the spacers emit nothing — one cycle plays
every item once, so airtime stays proportional to collection size. It is
anti-clumping, not fair-share. That was misread during this issue's own design
pass and would have collapsed #70 to "already shipped"; the distinction is the
whole justification for the feature, so it is now written down in both
decisions.md and the domain-model glossary rather than left to be rediscovered.

domain-model.md gains a Playback order row contrasting Shuffle / ShuffleInOrder /
WeightedShuffle, and notes on the multi-collection row that the two join entities
are mirrors.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 12:43:48 +02:00
co-authored by Claude Opus 4.8
parent d059ff5184
commit 1fd21e3645
2 changed files with 70 additions and 1 deletions
+68
View File
@@ -86,6 +86,7 @@ in-file entries.
- [2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred](#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred)
- [2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380)](#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380)
- [2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented](#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented)
- [2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)](#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70)
---
@@ -1198,3 +1199,70 @@ channel" has no reason to look under "Alternate Schedules", and on finding that
Fixed with a task-shaped **"Recipe: seasonal / holiday programming"** section in `channels.md` (both engines,
plus the gotchas above) and a `domain-model.md` glossary row. No production code changed, so no live-E2E
(same reasoning as #77).
## 2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)
`PlaybackOrder.WeightedShuffle = 9` ships the two behaviors #70 asked for — "air Show A 70% / Show B 30%"
and "no single show dominates" — as **one** order, because fair-share is the equal-weights case of weighted.
- **`ShuffleInOrder` does NOT already do fair-share, despite looking like it.** Its balanced shuffle
(keyj) pads every source to the longest with `Option<MediaItem>.None` spacers — but **spacers emit
nothing** (`ShuffleInOrderCollectionEnumerator.cs`, `result.AddRange(maybeItem)` over an `Option`). One
cycle therefore plays **every item exactly once**, so a 200-episode show still takes 10× the airtime of a
20-episode one. What it buys is **anti-clumping**: the small show is spread evenly instead of arriving in
bursts, and each show plays chronologically. That is a different product from "airs as often". Recorded
because the padding reads as equalization and this was misread once during #70's own design pass — the
distinction is the entire justification for the issue.
- **Plain `Shuffle` is already implicitly weighted by collection size** (Fisher–Yates over flattened items),
which is why "just use Shuffle" isn't fair-share either.
- **One new enum value, not two, and not a separate setting.** Fair-share = `WeightedShuffle` with weights
left at their default of 1, so the UI's "fair-share toggle" needs no second mechanism. A separate
non-enum "distribution" setting was rejected: it would add an orthogonal axis every `PlaybackOrder`
dispatch site must *also* consult, multiplying the blast radius below. Retrofitting weights onto
`ShuffleInOrder` was rejected: it would silently change shipped users' output.
- **Weights live on `MultiCollectionItem` AND `MultiCollectionSmartItem`** (`int Weight`, DB default 1,
dual-provider migration `AddMultiCollectionItemWeight`). The mirror is mandatory — omitting the smart
item un-weights smart-collection members silently. The DB default must be **1, not 0**: a 0-weight source
is filtered out by the enumerator, so a 0 backfill would drop every pre-existing member from a rotation.
Rejected carriers: `PlaylistItem` (sequential engine; its `Count`/`PlayAll` already mean "how much of this
source", and drain-N-consecutively `A A A B` is a different product from smooth interleave `A A B A`);
`CollectionItem` (wrong grain — per media item, and the largest table).
- **Applied on the `ShuffleInOrder`-shaped path only, because that is where source identity survives.**
`MultiCollectionGrouper` collapses sources into `GroupedMediaItem` + `.Distinct()`, so by the time the
`Shuffle` path has its list, *which source an item came from* is gone. `CollectionWithItems` gains
`int Weight = 1`; `WeightedShuffleCollectionEnumerator` consumes
`ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder` **unchanged** — the schedule-entity-free entry
point #380 reserved for this issue (no signature change, no god-factory, engines stay separate).
- **Stateless.** Smooth weighted round-robin (`acc += weight`; richest wins; pays the total) is a pure
function of (`Seed`, `Index`), so it restores by replay like its siblings and needs no per-source counters
(`CollectionEnumeratorState` has nowhere to put them). A rotation is sized so the source needing the most
picks works through all its items at its share; smaller sources loop inside it — that looping is what makes
equal weights mean equal airtime. **Weight ratios are data, not state**: a `Reset` build re-randomizes
`Playout.Seed` (fresh within-source shuffle and start point) but the ratios hold identically.
- **Ties break to the earliest source in list order** (strict `>`), keeping the sequence deterministic.
- **Within-source order is shuffled, not chronological** (custom-ordered collections are still honored).
Chronological inner order would make every rotation byte-identical, since source selection is
deterministic — the reseed on wrap would change nothing and the avoid-an-immediate-repeat retry could
never succeed. "Weighted **in order**" would be a separate order if wanted.
- **Cross-engine exposure is closed by write-path validation, not by touching the silent fallbacks.** An
unhandled `PlaybackOrder` fails **silently at five of six dispatch sites**: Classic substitutes
`RandomizedMediaCollectionEnumerator` (`PlayoutBuilder`'s `default:`, `// TODO: handle this error case
differently?`); `PlaylistEnumerator` has **no `default:` arm**, so the enumerator stays null and the item is
dropped from the playlist (and null is *legitimate* there for `SeasonEpisode` with `Count == 0`, so nothing
flags it); `BlockPlayoutBuilder` filters block items against an allow-list and `continue`s past the rest;
YAML/Scripted return `Option.None`, which their callers read as "no content". Only `MultiCollectionGroup`
throws. **A weighted order degrading to unweighted random is the worst case, because the output is supposed
to look arbitrary.** So `ReplacePlaylistItems`/`ReplaceBlockItems` **reject** `WeightedShuffle` at the write
path — if it can't be persisted where it isn't handled, the silent sites never see it — and YAML/Scripted
(which address orders by *name*, so `Enum.Parse` accepts it regardless) log a warning when an order falls
through unhandled. Making those pre-existing fallbacks loud is a real but separate defect class, filed as a
follow-up and an explicit non-goal here.
- **Weight is returned on the multi-collection GET, not just accepted on write.** The update replaces the
item list, so a client that reads, edits a name, and writes back would silently reset every weight to the
default if the read didn't carry it. Edits ride the existing `Version` token (If-Match/412, §7a).
- **Scope:** Classic engine only; no SPA (the schedule-editor weight UI is gated on #388). Plain
single/smart collections (the `GroupIntoFakeCollections` path) carry no persisted weights — fair-share
applies there by construction, and per-source weighting requires a multi collection. Auto-tune's
per-member weights (#385/#386) need their own carrier: an auto-tuned channel is backed by one live
SmartCollection, so its "sources" are members of a single collection, and a `PlaylistItem` cannot even
reference a search query. Nothing here forecloses it — the enumerator reads weights off
`CollectionWithItems` source-agnostically.
+2 -1
View File
@@ -66,10 +66,11 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **FillerPreset** | A reusable filler definition: `FillerKind` (PreRoll/MidRoll/PostRoll/Tail/Fallback; also `GuideMode=99`, `DecoDefault=100`) × `FillerMode` (None/Duration/Count/Pad/RandomCount) over a collection/media-item/multi-collection/smart-collection/playlist source, with an optional `Expression` (NCalc). Referenced from `ProgramScheduleItem` (Pre/Mid/Post/Tail/FallbackFillerId) and `Channel.FallbackFillerId`. | `FillerPreset`, `FillerKind`, `FillerMode` | `/app/filler-presets` |
| **Clock-boundary padding** (#77) | Snapping a schedule to clean `:00/:15/:30`-style guide times is **not a separate feature** — it is `FillerMode.Pad` + `PadToNearestMinute` on a `FillerPreset` (Classic), or the `pad_to_next`/`pad_until` YAML instructions (Sequential). Block playouts are inherently clock-anchored via `TemplateItem.StartTime`. The EPG reflects the padded boundary automatically (`ChannelGuideProjector` coalesces trailing filler into the programme window). No one-click per-channel toggle yet — deferred to a UI follow-up blocked on #388. See `decisions.md` 2026-07-17. | `FillerPreset` (`Pad`) | `/app/filler-presets` |
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `/app/schedules` (weight UI pending, gated on #388) |
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
| **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` |
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). | `MultiCollection` | `/app/multi-collections` (#151) |
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. | `MultiCollection` | `/app/multi-collections` (#151) |
| **RerunCollection** | One source (collection/media item/multi/smart) with separate `FirstRunPlaybackOrder` vs `RerunPlaybackOrder`. | `RerunCollection` | `/app/rerun-collections` (#152) |
| **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/playlists` |
| **Media kinds** | `CollectionType` enum distinguishes container kinds (Collection/TelevisionShow/TelevisionSeason/Artist/MultiCollection/SmartCollection/Playlist/RerunFirstRun/RerunRerun/SearchQuery) from leaf media kinds (Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream) plus synthetic `FakeCollection`/`FakePlaylistItem`. Concrete media entities: `Movie`, `Show`/`Season`/`Episode`, `Artist`/`MusicVideo`/`Song`, `OtherVideo`, `Image`, `RemoteStream` (`ErsatzTV.Core/Domain/MediaItem/`). | `MediaItem` subclasses | `/app/media?kind=...` |