Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.8 KiB
Design: per-schedule clock-boundary padding toggle (#392)
Issue: ersatztv#392 — "Clock-align convenience: one-click per-channel/schedule pad-to-boundary toggle + 60-min increment (SPA)". Follow-up to #77.
Status: approved design, pre-implementation.
Problem
Clock-boundary padding for Classic playouts already works end-to-end: a FillerPreset with
FillerMode.Pad + PadToNearestMinute = N, attached to a schedule item's Pre/Mid/PostRoll slot, snaps
each emitted content item up to the next N-minute clock boundary. AddFiller
(PlayoutModeSchedulerBase.cs) is called once per emitted content item inside every scheduler loop
(One/Multiple/Flood/Duration), so the padding is already per-episode, and it already tops up the gap
with the schedule item's FallbackFiller via FallbackFillerForPad when the pad preset's own content is
exhausted. Verified by PlayoutBuildGoldenTests.Classic_clock_padded +
ChannelGuideProjectorClockPadTests. This is the sched.clock-padding-existing decision (#77/#388).
The only gap is convenience: to clock-align a whole schedule today, a user must create a Pad
FillerPreset and hand-wire it into every schedule item's filler slot. #392 collapses that to a single
schedule-level setting. #388 (the design-system epic this UI work was gated behind) has since closed, so
the toggle is unblocked.
Approved decisions (from brainstorming)
- Home = per-schedule, not per-channel. Lives on
ProgramSchedulebeside its existing behavior flags (ShuffleScheduleItems,RandomStartPoint,FixedStartTimeBehavior). Per-channel was rejected as awkward plumbing (the channel drives a schedule; the builder would have to reach through the playout). - Fill source = the schedule item's
FallbackFillerif configured, else offline (dead-air gap). Reuses the existingFallbackFillerForPadtop-up path; no content-selection surface on the toggle. - Granularity = per-episode — every content item padded to a boundary (real-TV-listing look). This is what the existing per-item Pad path already does; no new per-item loop is needed.
- Precedence: when a schedule item has its own hand-wired Pad filler preset, the item's Pad
wins and the schedule-level pad is skipped for that item. This respects explicit per-item config and
avoids
AddFiller's existing "more than one Pad filler" guard-rail error.
The one genuinely-new behavior
Today, when a Pad path computes a gap but has no content and no fallback filler,
FallbackFillerForPad returns None and inserts nothing — so nextState.CurrentTime advances only to the
content item's end and the next item starts immediately (no offline gap, no boundary alignment). To
honor decision #2's "else offline", the schedule-level pad must, when nothing fills the gap, advance the
build clock to the boundary target time, leaving a true offline gap (an absence of a PlayoutItem, which
the streaming layer renders as "Channel is Offline"). This is the only new builder behavior; it is
localized to the pad-fill branch and only engages for the schedule-level synthetic pad, never changing the
existing per-item Pad-preset behavior.
Design
1. Data model
- Add
int? PadToNearestMinutetoErsatzTV.Core/Domain/ProgramSchedule.cs(null / absent = feature off). - EF config unchanged structurally (nullable int column); dual-provider migration via
scripts/add-migration.sh <Name>(Sqlite and MySql).
2. Builder wiring (ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs, AddFiller)
- At pad-preset selection (~L583-585): if the item has no Pre/Mid/Post filler with
FillerMode.Padand the parentProgramSchedule.PadToNearestMinuteis set, synthesize a content-less Pad behavior with that divisor (treat as PostRoll-style, matching theClassic_clock_paddedfixture). - The synthetic pad runs the existing boundary math. With no pad collection it skips
AddDurationFillerand goes straight toFallbackFillerForPad→ schedule item'sFallbackFillerif present, else the new offline branch (advanceCurrentTimetotargetTime). - Precedence guard: presence of any item-level
FillerMode.Padfiller suppresses the synthetic pad (decision #4). Never construct two pad fillers. - Access to the parent schedule flag: confirm
AddFillercan reach theProgramSchedule(viascheduleItem.ProgramSchedulenav or a builder-passed value). If the nav is not loaded on this path, thread theint?divisor down fromPlayoutBuilderrather than force-loading a nav — decided at implementation time, but the value flows one-way and read-only. - Determinism: the pad boundary math is a pure function of
playoutItem.StartOffset+ already-added filler durations (no wall-clock "now" dependency), andFallbackFillerForPadadvances its own enumerator, so no newPlayoutAnchor/Seedstate is required (same reason #77 needed none).
3. API
- Expose
padToNearestMinute(nullable int) on the ProgramSchedule response DTO and the create/update request DTO. Confirm the exact DTO/handler names during planning (schedule GET/PUT surface). - Regenerate OpenAPI artifacts: build the app project,
./scripts/update-openapi.sh,npm run generate:api(perprocess.pr-routine-sequence). Shipsv1.json,v1.d.ts,endpoint-index.mdin the same diff (blockingapi-docsgate).
4. SPA
- Add a "Pad to clock boundary" control to the schedule editor screen: a minute
Selectwith(none)= off and options5 / 10 / 15 / 30 / 60, bound topadToNearestMinute. Followspa-conventions.md. - Add
60toPAD_OPTIONSinweb/src/screens/FillerPresetsScreen.tsx(currently[5, 10, 15, 30]; the backend already accepts any integer).
5. Tests
- New golden test alongside
PlayoutBuildGoldenTests.Classic_clock_padded, driven by a schedule withPadToNearestMinuteset and no per-item Pad preset. Two cases:- with
FallbackFiller→ gap filled with fallback content, block ends on the boundary; - without
FallbackFiller→ offline gap, next item starts on the boundary.
- with
- A test asserting precedence: an item with its own Pad preset + schedule-level pad on → behaves exactly as the item's Pad preset (schedule pad suppressed, no double-pad error).
- Regenerate goldens only locally with
ETV_UPDATE_PLAYOUT_GOLDENS— never set it in CI. - TZ-independence holds only when the divisor divides 60; keep the existing golden's TZ-invariance note.
6. Docs
- New decision record extending (not reversing)
sched.clock-padding-existing: the per-schedule convenience layer over the existing per-item Pad machinery + the new offline-advance semantics. Add the record; cross-reference the predecessor; no archive move (not a supersession). - Update
docs/domain-model.md(ProgramSchedule field) anddocs/spa-conventions.mdif the schedule-editor control introduces a new pattern;docs/api-conventions.mdchecklist for the DTO change.
Non-goals / YAGNI
- Per-channel toggle (rejected — per-schedule chosen).
- Tunarr's pad-per-slot-vs-episode as a configurable distinction — per-episode is the single behavior.
- A content-selection surface on the toggle (fills with fallback-else-offline only).
- Any change to Sequential/YAML (
pad_to_next/pad_untilalready cover those engines) or Block.
Risk & review
Touches a DB migration + Classic builder logic + an API write-path DTO → independent review is
mandatory (process.independent-review-rubric). Live-E2E on the schedule editor round-trip
(release.live-e2e-required) before push.
Done-when (mirrors the issue)
- adversarial review passed
- backend option + API + Classic builder wiring, with tests
- SPA control added + 60-min increment option
- docs updated (domain-model / decisions / spa-conventions / api-conventions as applicable)