From 425b4b65f996054bc95a630d95f7308bc2a6871b Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 22 Jul 2026 21:24:35 +0200 Subject: [PATCH 01/14] docs(392): design spec for per-schedule clock-boundary padding toggle Co-Authored-By: Claude Opus 4.8 (1M context) --- ...7-22-clock-align-schedule-toggle-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-clock-align-schedule-toggle-design.md diff --git a/docs/superpowers/specs/2026-07-22-clock-align-schedule-toggle-design.md b/docs/superpowers/specs/2026-07-22-clock-align-schedule-toggle-design.md new file mode 100644 index 000000000..1c40c7e13 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-clock-align-schedule-toggle-design.md @@ -0,0 +1,117 @@ +# Design: per-schedule clock-boundary padding toggle (#392) + +**Issue:** [ersatztv#392](http://192.168.1.95:3000/timothy/ersatztv/issues/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) + +1. **Home = per-schedule**, not per-channel. Lives on `ProgramSchedule` beside 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). +2. **Fill source = the schedule item's `FallbackFiller` if configured, else offline** (dead-air gap). + Reuses the existing `FallbackFillerForPad` top-up path; no content-selection surface on the toggle. +3. **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. +4. **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? PadToNearestMinute` to `ErsatzTV.Core/Domain/ProgramSchedule.cs` (null / absent = feature off). +- EF config unchanged structurally (nullable int column); dual-provider migration via + `scripts/add-migration.sh ` (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.Pad` + **and** the parent `ProgramSchedule.PadToNearestMinute` is set, synthesize a content-less Pad behavior + with that divisor (treat as PostRoll-style, matching the `Classic_clock_padded` fixture). +- The synthetic pad runs the existing boundary math. With no pad collection it skips `AddDurationFiller` + and goes straight to `FallbackFillerForPad` → schedule item's `FallbackFiller` if present, else the new + offline branch (advance `CurrentTime` to `targetTime`). +- Precedence guard: presence of any item-level `FillerMode.Pad` filler suppresses the synthetic pad + (decision #4). Never construct two pad fillers. +- **Access to the parent schedule flag:** confirm `AddFiller` can reach the `ProgramSchedule` + (via `scheduleItem.ProgramSchedule` nav or a builder-passed value). If the nav is not loaded on this + path, thread the `int?` divisor down from `PlayoutBuilder` rather 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), and `FallbackFillerForPad` advances its own enumerator, so no + new `PlayoutAnchor`/`Seed` state 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` (per `process.pr-routine-sequence`). Ships `v1.json`, `v1.d.ts`, `endpoint-index.md` in the + same diff (blocking `api-docs` gate). + +### 4. SPA +- Add a "Pad to clock boundary" control to the schedule editor screen: a minute `Select` with `(none)` = off + and options `5 / 10 / 15 / 30 / 60`, bound to `padToNearestMinute`. Follow `spa-conventions.md`. +- Add `60` to `PAD_OPTIONS` in `web/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 with + `PadToNearestMinute` set 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. +- 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) and `docs/spa-conventions.md` if the schedule-editor + control introduces a new pattern; `docs/api-conventions.md` checklist 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_until` already 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) -- 2.47.3 From 9385bc8acd045739b39a9f86ab1656b605375578 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 22 Jul 2026 21:39:17 +0200 Subject: [PATCH 02/14] docs(392): implementation plan for per-schedule clock-boundary padding Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-22-clock-align-schedule-toggle.md | 882 ++++++++++++++++++ 1 file changed, 882 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-clock-align-schedule-toggle.md diff --git a/docs/superpowers/plans/2026-07-22-clock-align-schedule-toggle.md b/docs/superpowers/plans/2026-07-22-clock-align-schedule-toggle.md new file mode 100644 index 000000000..9b535ace7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-clock-align-schedule-toggle.md @@ -0,0 +1,882 @@ +# Per-schedule clock-boundary padding toggle (#392) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a per-`ProgramSchedule` "pad every content item up to the next N-minute clock boundary" option +that the Classic playout builder honors automatically — no hand-wired Pad `FillerPreset` — filling each gap +with the schedule item's `FallbackFiller` if configured, else an offline gap; plus a 60-minute increment in +the filler-preset editor. + +**Architecture:** Reuse the existing per-episode Pad machinery. `AddFiller` +(`PlayoutModeSchedulerBase`) already runs once per emitted content item. We add a **self-contained synthetic +-pad branch** in `AddFiller` that engages only when the item has no own Pad filler and its parent schedule +has `PadToNearestMinute` set; it computes the boundary target, fills with `FallbackFillerForPad`, and — when +no fallback content exists — returns an offline target that each scheduler uses to advance the build clock +(leaving an implicit offline gap, exactly as fixed-start items do). The schedule-level divisor reaches +`AddFiller` by populating the (currently null) `ProgramScheduleItem.ProgramSchedule` reverse navigation once, +centrally, in `PlayoutBuilder`. + +**Tech Stack:** C#/.NET 10, EF Core (dual-provider Sqlite + MySql migrations), MediatR CQRS, LanguageExt, +NUnit + Shouldly golden tests, ChicoryTV React SPA (Vite/TS), OpenAPI-generated TS types. + +## Global Constraints + +- Work in the worktree `/Users/timothy/ersatztv/.claude/worktrees/392-clock-align` on branch + `feat/392-clock-align-schedule` (off `origin/main`). Never commit in the shared tree. +- DB model changes need a migration in **both** `ErsatzTV.Infrastructure.Sqlite` and + `ErsatzTV.Infrastructure.MySql`, generated via `scripts/add-migration.sh ` (never hand-authored + snapshots). +- Any `/api/*` DTO change must regenerate OpenAPI artifacts in the SAME diff: `dotnet build ErsatzTV.sln` + → `./scripts/update-openapi.sh` → `cd web && npm run generate:api`; commit + `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`. +- Response DTOs carry a file-scoped `#nullable enable`; request DTOs do not. +- All ProgramSchedule DTOs/records/VMs are **positional records** — add the new field in a CONSISTENT + position (append to the end) across every one, or the positional constructors misalign. +- **Never** set `ETV_UPDATE_PLAYOUT_GOLDENS` in CI; only locally to regenerate a golden, then review + commit it. +- Before any push touching `.cs`: BOM-check the touched set (`git diff --name-only origin/main | grep '\.cs$'` + then `xxd -p | grep -c '^efbbbf'` must be 0) and run the format gate under `bash -c`. +- Commit hooks in the worktree can misfire — commit with `git -c core.hooksPath=/dev/null commit`, then run + the format/BOM checks manually. +- Independent cross-model review is MANDATORY before push (migration + builder + API write-path). +- Pad math is TZ-independent ONLY when the divisor divides 60 (all real IANA offsets are multiples of 15). + Keep golden divisors at 15; the SPA offers only 5/10/15/30/60. + +--- + +### Task 1: 60-minute increment in the filler-preset editor (SPA, standalone) + +Independent of everything else — ship first. + +**Files:** +- Modify: `web/src/screens/FillerPresetsScreen.tsx:42` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing other tasks depend on. + +- [ ] **Step 1: Add 60 to `PAD_OPTIONS`.** Change line 42 from: + +```tsx +const PAD_OPTIONS = [5, 10, 15, 30].map((minutes) => ({ label: String(minutes), value: String(minutes) })); +``` +to: +```tsx +const PAD_OPTIONS = [5, 10, 15, 30, 60].map((minutes) => ({ label: String(minutes), value: String(minutes) })); +``` + +- [ ] **Step 2: Build the web app to verify no type/lint error.** + +Run: `cd web && npm run build` +Expected: build succeeds. + +- [ ] **Step 3: Lint.** + +Run: `cd web && npm run lint` +Expected: no new errors in `FillerPresetsScreen.tsx`. + +- [ ] **Step 4: Commit.** + +```bash +git -c core.hooksPath=/dev/null add web/src/screens/FillerPresetsScreen.tsx +git -c core.hooksPath=/dev/null commit -m "feat(392): add 60-minute option to filler-preset pad increment" +``` + +--- + +### Task 2: `ProgramSchedule.PadToNearestMinute` column + dual-provider migration + +**Files:** +- Modify: `ErsatzTV.Core/Domain/ProgramSchedule.cs` +- Generated: `ErsatzTV.Infrastructure.Sqlite/Migrations/*_Add_ProgramSchedule_PadToNearestMinute.cs` (+ `.Designer.cs` + snapshot) +- Generated: `ErsatzTV.Infrastructure.MySql/Migrations/*_Add_ProgramSchedule_PadToNearestMinute.cs` (+ `.Designer.cs` + snapshot) + +**Interfaces:** +- Produces: `ProgramSchedule.PadToNearestMinute` — `public int? PadToNearestMinute { get; set; }` (null = feature off). + +- [ ] **Step 1: Add the property.** In `ErsatzTV.Core/Domain/ProgramSchedule.cs`, after +`FixedStartTimeBehavior` (currently the last scalar before the nav collections), add: + +```csharp + public int? PadToNearestMinute { get; set; } +``` + +- [ ] **Step 2: Generate both migrations.** From the worktree root: + +Run: `./scripts/add-migration.sh Add_ProgramSchedule_PadToNearestMinute` +Expected: creates one migration pair under each provider's `Migrations/` folder and updates both +`TvContextModelSnapshot.cs`. The generated `Up` should contain an `AddColumn` on `ProgramSchedule` +named `PadToNearestMinute`, `nullable: true`, no `defaultValue`. + +- [ ] **Step 3: Verify the generated Up/Down.** Open both generated `*_Add_ProgramSchedule_PadToNearestMinute.cs` +and confirm they match this shape (Sqlite `type: "INTEGER"`, MySql `type: "int"`): + +```csharp +protected override void Up(MigrationBuilder migrationBuilder) +{ + migrationBuilder.AddColumn( + name: "PadToNearestMinute", + table: "ProgramSchedule", + type: "INTEGER", // MySql: "int" + nullable: true); +} + +protected override void Down(MigrationBuilder migrationBuilder) +{ + migrationBuilder.DropColumn( + name: "PadToNearestMinute", + table: "ProgramSchedule"); +} +``` +If EF emitted `defaultValue: 0` or `nullable: false`, fix it to the above (a nullable int must have neither). + +- [ ] **Step 4: Build the two migration projects.** + +Run: `dotnet build ErsatzTV.Infrastructure.Sqlite ErsatzTV.Infrastructure.MySql` +Expected: build succeeds. + +- [ ] **Step 5: Commit.** + +```bash +git -c core.hooksPath=/dev/null add ErsatzTV.Core/Domain/ProgramSchedule.cs \ + ErsatzTV.Infrastructure.Sqlite/Migrations ErsatzTV.Infrastructure.MySql/Migrations +git -c core.hooksPath=/dev/null commit -m "feat(392): add ProgramSchedule.PadToNearestMinute column (dual-provider migration)" +``` + +--- + +### Task 3: Expose `padToNearestMinute` through the REST API + +Depends on Task 2. Adds the field to the read + write chains and regenerates OpenAPI. + +**Files:** +- Modify: `ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs` +- Modify: `ErsatzTV.Application/ProgramSchedules/Mapper.cs` +- Modify: `ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs` +- Modify: `ErsatzTV/Controllers/Api/ScheduleController.cs` (`ProjectToResponseModel`) +- Modify: `ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs` +- Modify: `ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs` +- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs` +- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs` +- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs` +- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs` +- Generated (regen, commit): `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` +- Test: `ErsatzTV.Application.Tests` (or the existing schedule handler test project — grep for + `UpdateProgramScheduleHandler`/`CreateProgramScheduleHandler` tests; if none exist, add a minimal one as below) + +**Interfaces:** +- Consumes: `ProgramSchedule.PadToNearestMinute` (Task 2). +- Produces: + - `ProgramScheduleResponseModel(..., FixedStartTimeBehavior, int? PadToNearestMinute)` + - `ProgramScheduleViewModel(..., FixedStartTimeBehavior, int? PadToNearestMinute, int Version)` — note + `PadToNearestMinute` goes **before** `Version` to match existing positional order (Version is last). + - `CreateScheduleRequest`/`UpdateScheduleRequest`/`CreateProgramSchedule`/`UpdateProgramSchedule` each gain a + trailing `int? PadToNearestMinute` positional member. + - TS type `components['schemas']['ProgramScheduleResponseModel'].padToNearestMinute: number | null` and the + matching Create/Update request schemas. + +- [ ] **Step 1: Write a failing handler round-trip test.** Find the existing schedule handler tests (grep +`FullyQualifiedName~ProgramSchedule` under `*.Tests`). If a `CreateProgramScheduleHandler`/`UpdateProgramScheduleHandler` +test class exists, add a test there; otherwise create +`ErsatzTV.Application.Tests/ProgramSchedules/ProgramSchedulePadToNearestMinuteTests.cs` mirroring the nearest +existing handler test's setup (in-memory `TvContext` via the shared test fixture). The test asserts the field +round-trips create→read and update→read: + +```csharp +[Test] +public async Task Create_and_update_persist_PadToNearestMinute() +{ + // Arrange: create a schedule with PadToNearestMinute = 30 via CreateProgramSchedule, + // then load it and assert entity.PadToNearestMinute == 30; + // then UpdateProgramSchedule with PadToNearestMinute = null and assert it clears to null. + // (Mirror the arrange/act pattern of the nearest existing ProgramSchedule handler test.) +} +``` + +Keep it minimal and consistent with the existing handler-test style (do not invent a new harness). + +- [ ] **Step 2: Run it — expect a COMPILE failure** (the new positional arg doesn't exist yet). + +Run: `dotnet test ErsatzTV.Application.Tests --filter "FullyQualifiedName~PadToNearestMinute"` +Expected: does not compile / FAIL. + +- [ ] **Step 3: Add the field to the read chain.** + +`ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs` — add `int? PadToNearestMinute` before `int Version`: +```csharp +public record ProgramScheduleViewModel( + int Id, + string Name, + bool KeepMultiPartEpisodesTogether, + bool TreatCollectionsAsShows, + bool ShuffleScheduleItems, + bool RandomStartPoint, + FixedStartTimeBehavior FixedStartTimeBehavior, + int? PadToNearestMinute, + int Version); +``` + +`ErsatzTV.Application/ProgramSchedules/Mapper.cs` — add the argument before `Version`: +```csharp + internal static ProgramScheduleViewModel ProjectToViewModel(ProgramSchedule programSchedule) => + new( + programSchedule.Id, + programSchedule.Name, + programSchedule.KeepMultiPartEpisodesTogether, + programSchedule.TreatCollectionsAsShows, + programSchedule.ShuffleScheduleItems, + programSchedule.RandomStartPoint, + programSchedule.FixedStartTimeBehavior, + programSchedule.PadToNearestMinute, + programSchedule.Version); +``` + +`ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs` — append the member: +```csharp +public record ProgramScheduleResponseModel( + int Id, + string Name, + bool KeepMultiPartEpisodesTogether, + bool TreatCollectionsAsShows, + bool ShuffleScheduleItems, + bool RandomStartPoint, + FixedStartTimeBehavior FixedStartTimeBehavior, + int? PadToNearestMinute); +``` + +`ErsatzTV/Controllers/Api/ScheduleController.cs` `ProjectToResponseModel` — append `vm.PadToNearestMinute`: +```csharp + private static ProgramScheduleResponseModel ProjectToResponseModel(ProgramScheduleViewModel vm) => + new( + vm.Id, + vm.Name, + vm.KeepMultiPartEpisodesTogether, + vm.TreatCollectionsAsShows, + vm.ShuffleScheduleItems, + vm.RandomStartPoint, + vm.FixedStartTimeBehavior, + vm.PadToNearestMinute); +``` + +- [ ] **Step 4: Add the field to the write chain.** + +`ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs` — append `int? PadToNearestMinute` to the record +and to `ToCreateCommand()`: +```csharp +public record CreateScheduleRequest( + string Name, + bool KeepMultiPartEpisodesTogether, + bool TreatCollectionsAsShows, + bool ShuffleScheduleItems, + bool RandomStartPoint, + FixedStartTimeBehavior FixedStartTimeBehavior, + int? PadToNearestMinute) +{ + public CreateProgramSchedule ToCreateCommand() => + new( + Name, + KeepMultiPartEpisodesTogether, + TreatCollectionsAsShows, + ShuffleScheduleItems, + RandomStartPoint, + FixedStartTimeBehavior, + PadToNearestMinute); +} +``` + +`ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs` — same, `ToCommand(int id)`: +```csharp +public record UpdateScheduleRequest( + string Name, + bool KeepMultiPartEpisodesTogether, + bool TreatCollectionsAsShows, + bool ShuffleScheduleItems, + bool RandomStartPoint, + FixedStartTimeBehavior FixedStartTimeBehavior, + int? PadToNearestMinute) +{ + public UpdateProgramSchedule ToCommand(int id) => + new( + id, + Name, + KeepMultiPartEpisodesTogether, + TreatCollectionsAsShows, + ShuffleScheduleItems, + RandomStartPoint, + FixedStartTimeBehavior, + PadToNearestMinute); +} +``` + +`ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs` — append the member: +```csharp +public record CreateProgramSchedule( + string Name, + bool KeepMultiPartEpisodesTogether, + bool TreatCollectionsAsShows, + bool ShuffleScheduleItems, + bool RandomStartPoint, + FixedStartTimeBehavior FixedStartTimeBehavior, + int? PadToNearestMinute) : IRequest>; +``` + +`ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs`: +```csharp +public record UpdateProgramSchedule( + int ProgramScheduleId, + string Name, + bool KeepMultiPartEpisodesTogether, + bool TreatCollectionsAsShows, + bool ShuffleScheduleItems, + bool RandomStartPoint, + FixedStartTimeBehavior FixedStartTimeBehavior, + int? PadToNearestMinute) : IRequest>; +``` + +`CreateProgramScheduleHandler.cs` — set the field when building the entity (in the `new ProgramSchedule { ... }` +initializer), normalizing a non-positive value to null so only meaningful divisors persist: +```csharp + PadToNearestMinute = request.PadToNearestMinute is int m && m > 0 ? m : null +``` +(add as the last initializer member; keep the existing trailing members intact). + +`UpdateProgramScheduleHandler.cs` `ApplyUpdateRequest` — add to BOTH the rebuild diff and the write: + +In `needToRefreshPlayout` (append a clause): +```csharp + programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior || + programSchedule.PadToNearestMinute != (request.PadToNearestMinute is int upm && upm > 0 ? upm : null); +``` +In the field writes (append): +```csharp + programSchedule.PadToNearestMinute = request.PadToNearestMinute is int upm2 && upm2 > 0 ? upm2 : null; +``` +(Use distinct local names `upm`/`upm2` to avoid a redeclaration; or hoist a single `int? normalizedPad` +local before both uses — implementer's choice, keep it compiling.) + +- [ ] **Step 5: Fix any OTHER positional constructor call sites the compiler flags.** Build and let the +compiler find every place that constructs these records positionally (e.g. Auto-Tune / channel-builder code +paths, Blazor-era callers if any remain). Add the new trailing arg (`null` where the caller has no pad +concept). Run: + +Run: `dotnet build ErsatzTV.sln` +Expected: build succeeds after you supply the new arg at each flagged call site. + +- [ ] **Step 6: Run the handler test — expect PASS.** + +Run: `dotnet test ErsatzTV.Application.Tests --filter "FullyQualifiedName~PadToNearestMinute"` +Expected: PASS. + +- [ ] **Step 7: Regenerate OpenAPI + TS types.** + +```bash +dotnet build ErsatzTV.sln +./scripts/update-openapi.sh +cd web && npm run generate:api && cd .. +``` +Expected: `v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` now show `padToNearestMinute` +on `ProgramScheduleResponseModel`, `CreateScheduleRequest`, `UpdateScheduleRequest`. + +- [ ] **Step 8: Verify the API-contract check is clean.** + +Run: `cd web && npm run check:api && cd ..` +Expected: no diff (generated types match committed `v1.json`). + +- [ ] **Step 9: Commit.** + +```bash +git -c core.hooksPath=/dev/null add ErsatzTV.Application ErsatzTV.Core ErsatzTV/Controllers \ + ErsatzTV/wwwroot/openapi/v1.json web/src/api/generated/v1.d.ts docs/endpoint-index.md +git -c core.hooksPath=/dev/null commit -m "feat(392): expose ProgramSchedule.padToNearestMinute on the REST API" +``` + +--- + +### Task 4: Classic builder — synthetic schedule-level pad + offline advance + +Depends on Task 2. The core behavior. Self-contained synthetic-pad branch in `AddFiller`; reverse-nav +population in `PlayoutBuilder`; offline-target advance in the 4 schedulers. + +**Files:** +- Modify: `ErsatzTV.Core/Scheduling/PlayoutBuilder.cs` (populate `ProgramScheduleItem.ProgramSchedule` reverse nav) +- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs` (`AddFiller` return type + synthetic-pad branch) +- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs` (line ~115-138) +- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs` (line ~203-221) +- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs` (line ~139-150) +- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs` (line ~85-97) +- Test: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` +- Golden data: `ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/classic-schedule-clock-padded-offline.txt`, + `...-fallback.txt` + +**Interfaces:** +- Consumes: `ProgramSchedule.PadToNearestMinute` (Task 2); the existing + `FallbackFillerForPad(playoutBuilderState, enumerators, scheduleItem, duration, cancellationToken)` → + `Option` (unchanged). +- Produces: `AddFiller(...)` returns `(List Items, DateTimeOffset? OfflinePadTarget)` instead of + `List`. `OfflinePadTarget` is non-null ONLY for a synthetic schedule pad that emitted no + filler — callers must advance `CurrentTime` to it when it exceeds the content/filler end. + +- [ ] **Step 1: Write failing golden-invariant tests.** In `PlayoutBuildGoldenTests.cs`, add two tests plus +their fixtures, modeled on `Classic_clock_padded` / `SeedPaddedData` (which build a `ProgramScheduleItemOne`, +a `ProgramSchedule`, a `Playout`, and reference data). The new fixtures set +`schedule.PadToNearestMinute = 15` and attach **no** Pad `FillerPreset` to the item. + +Offline variant — no `FallbackFiller`, so gaps are offline (no filler items emitted), and each content item +after the first starts on a :15 boundary: +```csharp + // Issue #392: schedule-level clock padding with NO fallback filler → each content item is padded up to + // the next :15 boundary with an OFFLINE gap (no filler items). Proves the synthetic schedule pad advances + // the build clock to the boundary even when nothing fills the gap. + [Test] + public async Task Classic_schedule_clock_padded_offline() + { + (List items, Dictionary titles) = await BuildSchedulePaddedPlayout(withFallback: false); + + List content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList(); + content.Count.ShouldBeGreaterThan(2); + + // No filler of any kind is emitted (offline gaps only). + items.ShouldNotContain(i => i.FillerKind != FillerKind.None); + + foreach (PlayoutItem item in content.Skip(1)) + { + (item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary"); + item.Start.Second.ShouldBe(0); + } + + await CompareGolden("classic-schedule-clock-padded-offline.txt", items, titles); + } +``` + +Fallback variant — a `FallbackFiller` IS configured, so the gap is filled with `FillerKind.Fallback` content +up to the boundary: +```csharp + // Issue #392: schedule-level clock padding WITH a fallback filler → gaps fill with Fallback content up to + // the :15 boundary (no offline gap). + [Test] + public async Task Classic_schedule_clock_padded_fallback() + { + (List items, Dictionary titles) = await BuildSchedulePaddedPlayout(withFallback: true); + + items.ShouldContain(i => i.FillerKind == FillerKind.Fallback); + + List content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList(); + foreach (PlayoutItem item in content.Skip(1)) + { + (item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary"); + item.Start.Second.ShouldBe(0); + } + + await CompareGolden("classic-schedule-clock-padded-fallback.txt", items, titles); + } +``` + +Add the fixture builder `BuildSchedulePaddedPlayout(bool withFallback)` + `SeedSchedulePaddedData` + +`GetSchedulePaddedReferenceData`, copied from `BuildPaddedPlayout`/`SeedPaddedData`/`GetPaddedReferenceData` +(lines 727-927) with these changes: +- The `ProgramScheduleItemOne` has **no** `PostRollFiller`. When `withFallback`, set `FallbackFiller` to a + `FillerPreset { FillerKind = FillerKind.Fallback, FillerMode = FillerMode.None, CollectionType = CollectionType.Collection, Collection = fillerCollection, CollectionId = fillerCollection.Id }` and set the + item's `FallbackFillerId`. When not, leave `FallbackFiller` null. +- The `ProgramSchedule` sets `PadToNearestMinute = 15` (and `Name = "Schedule Padded Test Schedule"`). +- `GetSchedulePaddedReferenceData` mirrors `GetPaddedReferenceData` but the schedule query must + `.Include(ps => ps.Items).ThenInclude(psi => psi.FallbackFiller)` (needed for the fallback variant) in + addition to `Collection`/`MediaItem`. It does NOT need `PostRollFiller`. + +(Do NOT create the golden `.txt` files yet — Step 4 generates them.) + +- [ ] **Step 2: Run the new tests — expect compile failure / FAIL** (the field and behavior don't exist / the +golden files are missing). + +Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"` +Expected: FAIL. + +- [ ] **Step 3: Implement the builder change.** + +**(a) `PlayoutBuilder.cs` — populate the reverse nav once, centrally.** At the top of the `Build(...)` method +body (after parameters are in scope, before the day loop), set each schedule item's parent so `AddFiller` can +read `scheduleItem.ProgramSchedule.PadToNearestMinute`. Use `referenceData.ProgramSchedule` and each alternate: + +```csharp + // #392: the build query does not populate the ProgramScheduleItem.ProgramSchedule reverse nav + // (AsNoTracking). Populate it so schedule-level settings (PadToNearestMinute) are readable in AddFiller. + if (referenceData.ProgramSchedule?.Items is not null) + { + foreach (ProgramScheduleItem item in referenceData.ProgramSchedule.Items) + { + item.ProgramSchedule = referenceData.ProgramSchedule; + } + } + + foreach (ProgramScheduleAlternate alternate in referenceData.ProgramScheduleAlternates) + { + if (alternate.ProgramSchedule?.Items is not null) + { + foreach (ProgramScheduleItem item in alternate.ProgramSchedule.Items) + { + item.ProgramSchedule = alternate.ProgramSchedule; + } + } + } +``` +Verify the exact accessor names against `PlayoutReferenceData.cs` and `ProgramScheduleAlternate` (the record +exposes `ProgramSchedule`). If an accessor differs, adjust — the intent is "every scheduled item points at its +own parent schedule." + +**(b) `PlayoutModeSchedulerBase.cs` — change `AddFiller` return type + add the synthetic-pad branch.** + +Change the signature (line 282) return type to a named tuple: +```csharp + internal (List Items, DateTimeOffset? OfflinePadTarget) AddFiller( + PlayoutBuilderState playoutBuilderState, + Dictionary enumerators, + ProgramScheduleItem scheduleItem, + PlayoutItem playoutItem, + List chapters, + PlayoutBuildWarnings warnings, + CancellationToken cancellationToken) +``` +Update the two guard-rail early returns (lines 302, 313) from `return [playoutItem];` to +`return ([playoutItem], null);`. Add a local near the top of the method (before the pad section): +```csharp + DateTimeOffset? offlinePadTarget = null; +``` +Immediately BEFORE the existing item-pad section (the `foreach (FillerPreset padFiller in Optional( +allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))` at line 583), +insert the self-contained synthetic branch. It engages ONLY when the item has no own Pad filler and the parent +schedule has a positive divisor — mutually exclusive with the existing block: +```csharp + // #392: schedule-level clock padding. Applies only when the item has no own Pad filler preset (that + // wins) and the parent schedule declares a positive divisor. Reuses the existing pad boundary math + + // FallbackFillerForPad; when no fallback content exists, records an offline target so the caller + // advances the build clock to the boundary (leaving an offline gap). + bool itemHasPadFiller = + allFiller.Any(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue); + if (!itemHasPadFiller && + scheduleItem.ProgramSchedule?.PadToNearestMinute is int schedulePadMinutes && + schedulePadMinutes > 0) + { + TimeSpan totalDuration = result.Aggregate( + TimeSpan.Zero, + (acc, i) => acc + (i.FinishOffset - i.StartOffset)); + + int currentMinute = (playoutItem.StartOffset + totalDuration).Minute; + int targetMinute = (currentMinute + schedulePadMinutes - 1) / schedulePadMinutes * schedulePadMinutes; + + DateTimeOffset almostTargetTime = playoutItem.StartOffset + totalDuration - + TimeSpan.FromMinutes(currentMinute) + + TimeSpan.FromMinutes(targetMinute); + + var targetTime = new DateTimeOffset( + almostTargetTime.Year, + almostTargetTime.Month, + almostTargetTime.Day, + almostTargetTime.Hour, + almostTargetTime.Minute, + 0, + almostTargetTime.Offset); + + if (targetTime <= playoutItem.StartOffset + totalDuration) + { + targetTime = targetTime.AddMinutes(schedulePadMinutes); + } + + TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset; + if (remainingToFill > TimeSpan.Zero) + { + Option maybeFallback = FallbackFillerForPad( + playoutBuilderState, + enumerators, + scheduleItem, + remainingToFill, + cancellationToken); + + if (maybeFallback.IsSome) + { + foreach (PlayoutItem fallbackItem in maybeFallback) + { + result.Add(fallbackItem); + } + } + else + { + // No fallback content: leave an offline gap up to the boundary. + offlinePadTarget = targetTime; + } + } + } +``` +Leave the existing item-pad `foreach` block (583-753) UNCHANGED. Finally, update the method's tail (line 766) +from `return result;` to: +```csharp + return (result, offlinePadTarget); +``` +Confirm the sequential Start/Finish rewrite (lines 755-766) runs unconditionally at the method tail (it does — +it precedes `return`), so an appended fallback item is sequenced correctly. + +**(c) Update the 4 scheduler call sites** to destructure the tuple and honor `OfflinePadTarget`. + +`PlayoutModeSchedulerFlood.cs` (~115-138) — replace: +```csharp + List maybePlayoutItems = AddFiller( + nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken); + + DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset); +``` +with: +```csharp + (List maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller( + nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken); + + DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset); + if (clockPadTarget is { } floodPadTarget && floodPadTarget > itemEndTimeWithFiller) + { + itemEndTimeWithFiller = floodPadTarget; + } +``` +(the existing `nextState = nextState with { CurrentTime = itemEndTimeWithFiller, ... }` then picks up the advance). + +`PlayoutModeSchedulerDuration.cs` (~203-221) — same destructure; after computing +`itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);` add the same `if (clockPadTarget is { } durPadTarget && durPadTarget > itemEndTimeWithFiller) itemEndTimeWithFiller = durPadTarget;` guard. + +`PlayoutModeSchedulerMultiple.cs` (~139-150) — it currently inlines `.Max` into `CurrentTime`. Refactor: +```csharp + (List filled, DateTimeOffset? clockPadTarget) = AddFiller( + nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken); + playoutItems.AddRange(filled); + + DateTimeOffset multipleEnd = playoutItems.Max(pi => pi.FinishOffset); + if (clockPadTarget is { } mulPadTarget && mulPadTarget > multipleEnd) + { + multipleEnd = mulPadTarget; + } + + nextState = nextState with + { + CurrentTime = multipleEnd, +``` +(Adapt to the exact surrounding structure — the key change is that `CurrentTime` becomes +`max(items' FinishOffset, clockPadTarget)`. Preserve everything else in the `with` expression.) + +`PlayoutModeSchedulerOne.cs` (~85-97) — same shape: +```csharp + (List playoutItems, DateTimeOffset? clockPadTarget) = AddFiller( + playoutBuilderState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken); + + DateTimeOffset oneEnd = playoutItems.Max(pi => pi.FinishOffset); + if (clockPadTarget is { } onePadTarget && onePadTarget > oneEnd) + { + oneEnd = onePadTarget; + } + + PlayoutBuilderState nextState = playoutBuilderState with + { + CurrentTime = oneEnd + }; +``` + +- [ ] **Step 4: Grep for any OTHER `AddFiller(` callers** the four above missed, and update them the same way. + +Run: `grep -rn "AddFiller(" ErsatzTV.Core ErsatzTV.Core.Tests` +Expected: only the four schedulers (and possibly a direct test). Update any extra caller to destructure the tuple. + +- [ ] **Step 5: Build.** + +Run: `dotnet build ErsatzTV.Core` +Expected: build succeeds. + +- [ ] **Step 6: Generate the two goldens, review, and verify.** + +```bash +ETV_UPDATE_PLAYOUT_GOLDENS=1 dotnet test ErsatzTV.Core.Tests \ + --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded" +``` +Expected: writes `classic-schedule-clock-padded-offline.txt` and `...-fallback.txt` (tests report +Inconclusive). **Read both golden files** and confirm: offline variant has only `None` content lines with +boundary-aligned starts and NO filler lines; fallback variant interleaves `Fallback` filler lines ending on +:15 boundaries. Then run WITHOUT the env var: +```bash +dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded" +``` +Expected: PASS. + +- [ ] **Step 7: Run the FULL golden suite** to prove no regression to the existing `Classic_clock_padded` +(item-level pad path must be byte-identical — the synthetic branch is gated off when an item pad exists). + +Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests"` +Expected: PASS (all goldens, including `Classic_clock_padded`, unchanged). + +- [ ] **Step 8: Add a precedence test** proving an item's own Pad filler wins when the schedule pad is also set. +Add to `PlayoutBuildGoldenTests.cs` a test that builds the SAME fixture as `Classic_clock_padded` but also sets +`schedule.PadToNearestMinute = 30` on that schedule, and asserts the output is byte-identical to the existing +`classic-clock-padded.txt` golden (the item's :15 PostRoll pad wins; the schedule's :30 is ignored): +```csharp + // #392: an item's own Pad filler takes precedence over the schedule-level pad (no double-pad). + [Test] + public async Task Classic_item_pad_wins_over_schedule_pad() + { + (List items, Dictionary titles) = await BuildPaddedPlayout(schedulePadMinutes: 30); + await CompareGolden("classic-clock-padded.txt", items, titles); // identical to the item-pad-only golden + } +``` +Give `BuildPaddedPlayout`/`SeedPaddedData` an optional `int? schedulePadMinutes = null` parameter that, when +set, assigns `schedule.PadToNearestMinute`; the default keeps every existing caller unchanged. + +Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_item_pad_wins_over_schedule_pad"` +Expected: PASS (reuses the existing golden — no new golden file). + +- [ ] **Step 9: Commit.** + +```bash +git -c core.hooksPath=/dev/null add ErsatzTV.Core/Scheduling ErsatzTV.Core.Tests/Scheduling/Goldens +git -c core.hooksPath=/dev/null commit -m "feat(392): honor ProgramSchedule.PadToNearestMinute in the Classic builder" +``` + +--- + +### Task 5: SPA schedule editor control + +Depends on Task 3 (generated TS type). Adds the pad control to the schedule form. + +**Files:** +- Modify: `web/src/schedules/ScheduleForm.tsx` + +**Interfaces:** +- Consumes: `ProgramSchedule.padToNearestMinute` / `CreateScheduleRequest.padToNearestMinute` (Task 3, generated types). +- Produces: nothing downstream. + +- [ ] **Step 1: Seed state from the loaded schedule.** In `ScheduleForm.tsx`, next to the existing +`randomStart` / `fixedBehavior` state (lines ~25-26), add (value is a string for the `Select`, `''` = none): +```tsx + const [padToNearest, setPadToNearest] = useState( + schedule?.padToNearestMinute != null ? String(schedule.padToNearestMinute) : '' + ); +``` + +- [ ] **Step 2: Include it in the request body.** In the `body: CreateScheduleRequest = { ... }` object +(lines ~41-48), append: +```tsx + padToNearestMinute: padToNearest === '' ? null : Number(padToNearest) +``` + +- [ ] **Step 3: Render the control.** After the `Fixed start time behavior` `Select` (lines ~96-102), add an +object-options `Select` with a `(none)` entry (mirrors `FillerPresetsScreen`'s pad control): +```tsx + setPadToNearest(e.target.value)} + options={[ + { label: '(none)', value: '' }, + { label: '5', value: '5' }, + { label: '10', value: '10' }, + { label: '15', value: '15' }, + { label: '30', value: '30' }, + { label: '60', value: '60' } + ]} + /> ); -- 2.47.3 From e99e72ee71a9c478977b23f632415c71d3b0d5af Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 22 Jul 2026 23:40:45 +0200 Subject: [PATCH 10/14] docs(392): record per-schedule clock-padding decision + domain-model field --- docs/decisions.md | 27 +++++++++++++++++++++++++++ docs/decisions/README.md | 1 + docs/domain-model.md | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/decisions.md b/docs/decisions.md index 1f28ff282..6e51946aa 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1305,6 +1305,8 @@ into a schedule item's roll slot. That toggle is mostly SPA work and is deferred the #388 design-system sync epic** (all UI work is currently gated on #388), which also covers adding a 60-minute increment to the filler-preset editor's options (the backend already accepts any integer). +Extended by `sched.clock-padding-schedule-toggle` (#392). + ## 2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380) `key: sched.shuffle-source-builder` · `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. @@ -3561,3 +3563,28 @@ no scheduling logic of its own. So "Scripted is un-golden-able" conflates two di The #381 "documented decision" arm correctly deferred the *pipeline* golden; it overstated the case by writing off engine-level coverage too. Scripted scheduling *behavior* is now covered in-process; only the external-process pipeline remains #563's. + +## 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392) + +`key: sched.clock-padding-schedule-toggle` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none` +**Rule:** A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388). +**Signals:** per-schedule clock padding, PadToNearestMinute on ProgramSchedule, offline gap on pad, ClockPadOfflineTarget, synthetic Pad without FillerPreset · paths: `PlayoutModeSchedulerBase.AddFiller`, `PlayoutSchedulerResult.ClockPadOfflineTarget`, `FallbackFillerForPad`, `/app/schedules` · issues: #392, #77, #388 +**Mechanics:** `PlayoutBuildGoldenTests` (One/Flood/Duration/Multiple clock-pad cases), midnight-crossing invariant tests + +A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content +item up to the next N-minute clock boundary WITHOUT a hand-wired Pad `FillerPreset`. It reuses the existing +per-content-item Pad path (`PlayoutModeSchedulerBase.AddFiller`, already called once per emitted item): a +self-contained synthetic branch engages only when the item has no own `FillerMode.Pad` filler (the item's Pad +wins — no double-pad) and the parent schedule declares a positive divisor. The gap fills with the schedule +item's `FallbackFiller` via the existing `FallbackFillerForPad`; when no fallback content exists, the branch +records an offline target and the four schedulers advance `PlayoutBuilderState.CurrentTime` to the boundary, +leaving an implicit offline gap (the same representation fixed-start items use — absence of a `PlayoutItem`, +rendered as "Channel is Offline" at stream time). That advance is carried by a transient per-build +`PlayoutSchedulerResult.ClockPadOfflineTarget` (never serialized — no anchor schema, no migration); the +day-seam anchor clamp is exempted by exact equality with that target. + +This is the per-schedule convenience layer deferred behind #388 in `sched.clock-padding-existing`; that +record's per-item Pad-preset behavior is unchanged. Determinism needs no new anchor/seed state (the pad math +is a pure function of offsets). Coverage is per-scheduler-mode (One/Flood/Duration/Multiple) via golden and +invariant tests across midnight crossings. The SPA schedule editor exposes it as a 5/10/15/30/60 minute +picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d513f1261..1c53a4697 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -114,6 +114,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `sched.autotune-per-channel-overrides` | Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425. | 2026-07-17 | [link](../decisions.md#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385) | | `sched.autotune-per-source-weights` | Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT. | 2026-07-18 | [link](../decisions.md#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425) | | `sched.clock-padding-existing` | Clock-boundary padding already exists via `FillerPreset`'s `FillerMode.Pad` (Classic) and `pad_to_next`/`pad_until` (Sequential/YAML); #77 is closed as verified+documented, not built new, with a one-click per-channel toggle deferred behind the #388 design-system epic. | 2026-07-17 | [link](../decisions.md#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred) | +| `sched.clock-padding-schedule-toggle` | A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388). | 2026-07-22 | [link](../decisions.md#2026-07-22--per-schedule-clock-boundary-padding-is-a-synthetic-content-less-pad-over-the-existing-per-episode-machinery-392) | | `sched.playbackorder-support-matrix` | Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction. | 2026-07-18 | [link](../decisions.md#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403) | | `sched.reshuffle-scoped-reset` | `POST /api/v1/playouts/{id}/reshuffle` runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears anchors/rerun-history) then enqueues a scoped `Reset` build, so reshuffle always reseeds — even for the non-Classic kinds `Reset` alone wouldn't reseed; `Playout.Seed` is surfaced on list/detail DTOs as visible confirmation. | 2026-07-16 | [link](../decisions.md#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71) | | `sched.seasonal-scheduling-existing` | Seasonal/date-conditional scheduling already ships first-class via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block `PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match in `Index` order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code. | 2026-07-17 | [link](../decisions.md#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented) | diff --git a/docs/domain-model.md b/docs/domain-model.md index 42ae9a278..b0d4b75ae 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -64,7 +64,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **DecoTemplate** | Time-of-day (`DecoTemplateItem.StartTime`/`EndTime`) calendar of `Deco`s, assigned to a playout via `PlayoutTemplate.DecoTemplateId` (same row as the Block-template assignment — one `PlayoutTemplate` entry carries both a `Template` and an optional `DecoTemplate`). | `DecoTemplate`, `DecoTemplateItem`, `DecoTemplateGroup` | `/app/deco-templates` | | **Default deco vs deco templates** | `Playout.DecoId` = one static deco for the whole playout; `PlayoutTemplate.DecoTemplateId` = a time-varying deco schedule. Both are optional and independent. | `Playout`, `PlayoutTemplate` | `/app/playouts/{id}/templates` | | **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` | +| **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). `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) is the per-schedule convenience layer (#392): optional clock-boundary padding divisor (minutes); when set, the Classic builder pads each content item up to the next boundary, filling with the item's FallbackFiller else offline. See `decisions.md` 2026-07-17 and 2026-07-22. | `FillerPreset` (`Pad`), `ProgramSchedule` (`PadToNearestMinute`) | `/app/filler-presets`, `/app/schedules` | | **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` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` | | **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` | -- 2.47.3 From 6a05363e2a356424c1c22f8880cb4e80a15e0315 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 22 Jul 2026 23:45:15 +0200 Subject: [PATCH 11/14] design(392): mirror 60-min pad option into FillerPresets prototype Schedule-level pad control has no prototype counterpart (the schedule-level scalar form is not modeled in Schedules.jsx); nothing to mirror there. Co-Authored-By: Claude Opus 4.8 (1M context) --- design-system/templates/chicorytv-admin/FillerPresets.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design-system/templates/chicorytv-admin/FillerPresets.jsx b/design-system/templates/chicorytv-admin/FillerPresets.jsx index 468d79091..d42f70c2e 100644 --- a/design-system/templates/chicorytv-admin/FillerPresets.jsx +++ b/design-system/templates/chicorytv-admin/FillerPresets.jsx @@ -23,7 +23,7 @@ const KIND_OPTIONS = ["Pre-Roll", "Mid-Roll", "Post-Roll", "Tail", "Fallback"]; const MODE_OPTIONS = ["Duration", "Count", "Pad", "Random Count"]; - const PAD_OPTIONS = ["5", "10", "15", "30"]; + const PAD_OPTIONS = ["5", "10", "15", "30", "60"]; const COLLECTION_TYPE_OPTIONS = ["Collection", "Television Show", "Television Season", "Artist", "Multi Collection", "Smart Collection", "Playlist"]; function Row({ label, help, control = 320, first = false, children }) { -- 2.47.3 From 767f96802ef094f5ec68ddd30fe33296e5d3844a Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 23 Jul 2026 07:58:10 +0200 Subject: [PATCH 12/14] fix(392): apply schedule-level pad to Fill-With-Group items (reverse nav lost in DeepCopy) --- .../Goldens/PlayoutBuildGoldenTests.cs | 167 ++++++++++++++++++ ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 4 + 2 files changed, 171 insertions(+) diff --git a/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs b/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs index 08f9652e2..b71f828ee 100644 --- a/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs @@ -223,6 +223,34 @@ public class PlayoutBuildGoldenTests } } + // Regression for the whole-branch-review defect: Fill-With-Group schedule items (FillWithGroupMode + // .FillWithOrderedGroups / FillWithShuffledGroups) are scheduled via a FAKE ProgramScheduleItem that + // PlayoutBuilder synthesizes with DeepCopy() (Newtonsoft serialization). ProgramScheduleItem + // .ProgramSchedule is [JsonIgnore]'d there, so without reassigning it on the copy, the schedule-level + // PadToNearestMinute silently no-ops for fill-with-group items only — the normal (non-group) path + // reads the schedule nav that Build() populates centrally and was never broken. No golden: this is an + // invariant-only regression test (boundary alignment + zero filler), the same assertions + // Classic_schedule_clock_padded_offline uses for the non-group path. + [Test] + public async Task Schedule_clock_padded_fill_with_group_offline() + { + List items = await BuildSchedulePaddedFillWithGroupPlayout(); + + List content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList(); + content.Count.ShouldBeGreaterThan(2); + + // No filler of any kind is emitted (offline gaps only). + items.ShouldNotContain(i => i.FillerKind != FillerKind.None); + + foreach (PlayoutItem item in content.Skip(1)) + { + (item.Start.Minute % 15).ShouldBe( + 0, + $"fill-with-group content item at {item.Start:HH:mm:ss} is not on a :15 boundary"); + item.Start.Second.ShouldBe(0); + } + } + // Classic + PlaybackOrder.Shuffle: exercises PlayoutBuilder's call into the shuffle-source helper // (GetGroupedMediaItemsForShuffle) that #380 moves to ShuffleSourceBuilder, plus the wiring into // ShuffledMediaCollectionEnumerator. Unlike the chronological fixture, shuffle output depends on the @@ -1430,6 +1458,145 @@ public class PlayoutBuildGoldenTests return playout.Id; } + // Regression harness for the Fill-With-Group DeepCopy defect: same shape as + // SeedSchedulePaddedModeData (off-boundary durations, no item-level Pad filler, no FallbackFiller + // -> offline), except the single ProgramScheduleItemMultiple sets FillWithGroupMode so PlayoutBuilder + // schedules it via a synthesized (DeepCopy'd) fake schedule item instead of the original. + private async Task SeedSchedulePaddedFillWithGroupData(CancellationToken cancellationToken) + { + await using TvContext context = _dbContextFactory.CreateDbContext(); + + var path = new LibraryPath { Path = "Schedule Padded FillWithGroup LibraryPath" }; + var library = new LocalLibrary + { + MediaKind = LibraryMediaKind.Movies, + Paths = new List { path }, + MediaSource = new LocalMediaSource() + }; + await context.Libraries.AddAsync(library, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + // Off-boundary durations (22/37/52 min), same as SeedSchedulePaddedData, so padding to :15 is visible. + int[] durationsMinutes = [22, 37, 52]; + var movies = new List(); + for (var i = 1; i <= 3; i++) + { + movies.Add(new Movie + { + MediaVersions = new List { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } }, + MovieMetadata = new List + { + new() { Title = $"Schedule Padded FillWithGroup Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) } + }, + LibraryPath = path, + LibraryPathId = path.Id + }); + } + + await context.Movies.AddRangeAsync(movies, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + var contentCollection = new Collection + { + Name = "Schedule Padded FillWithGroup Content Collection", + MediaItems = movies.Cast().ToList() + }; + await context.Collections.AddAsync(contentCollection, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + var scheduleItem = new ProgramScheduleItemMultiple + { + Collection = contentCollection, + CollectionId = contentCollection.Id, + CollectionType = CollectionType.Collection, + MultipleMode = MultipleMode.Count, + Count = "3", + PlaybackOrder = PlaybackOrder.Chronological, + FillWithGroupMode = FillWithGroupMode.FillWithOrderedGroups + }; + + var scheduleItems = new List { scheduleItem }; + + var ffmpegProfile = new FFmpegProfile { Name = "Schedule Padded FillWithGroup FFmpeg Profile" }; + await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-00000000000d")) + { + Name = "Schedule Padded FillWithGroup Channel", + Number = "13", + FFmpegProfile = ffmpegProfile, + FFmpegProfileId = ffmpegProfile.Id + }; + await context.Channels.AddAsync(channel, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + var schedule = new ProgramSchedule + { + Name = "Schedule Padded FillWithGroup Schedule", + Items = scheduleItems, + PadToNearestMinute = 15 + }; + await context.ProgramSchedules.AddAsync(schedule, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + var playout = new Playout + { + Channel = channel, + ChannelId = channel.Id, + ProgramSchedule = schedule, + ProgramScheduleId = schedule.Id, + ScheduleKind = PlayoutScheduleKind.Classic + }; + await context.Playouts.AddAsync(playout, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return playout.Id; + } + + private async Task> BuildSchedulePaddedFillWithGroupPlayout() + { + var cancellationToken = CancellationToken.None; + + int playoutId = await SeedSchedulePaddedFillWithGroupData(cancellationToken); + + var builder = new PlayoutBuilder( + new ConfigElementRepository(_dbContextFactory), + new MediaCollectionRepository(Substitute.For(), _dbContextFactory), + new TelevisionRepository(_dbContextFactory, NullLogger.Instance), + new ArtistRepository(_dbContextFactory), + Substitute.For(), + new MockFileSystem(), + Substitute.For(), + NullLogger.Instance); + + await using TvContext context = _dbContextFactory.CreateDbContext(); + + Playout playout = await context.Playouts + .Include(p => p.ProgramScheduleAnchors) + .ThenInclude(a => a.EnumeratorState) + .Include(p => p.FillGroupIndices) + .ThenInclude(fgi => fgi.EnumeratorState) + .SingleAsync(p => p.Id == playoutId, cancellationToken); + + PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId); + + Either result = await builder.Build( + playout, + referenceData, + PlayoutBuildResult.Empty, + PlayoutBuildMode.Reset, + Start, + Start.AddDays(2), + cancellationToken); + + PlayoutBuildResult buildResult = result.Match( + r => r, + error => throw new AssertionException($"Build returned error: {error.Value}")); + + return buildResult.AddedItems; + } + // --- Block builder --- // // BlockPlayoutBuilder maps template times-of-day to absolute instants via diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 496351e67..a9e2e4018 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -725,6 +725,10 @@ public class PlayoutBuilder : IPlayoutBuilder } var copyScheduleItem = scheduleItem.DeepCopy(); + // DeepCopy uses Newtonsoft serialization, and ProgramSchedule is [JsonIgnore]'d there, + // so the reverse nav is lost on the copy. Reassign it so schedule-level settings + // (e.g. PadToNearestMinute) remain readable in AddFiller for Fill-With-Group items. + copyScheduleItem.ProgramSchedule = scheduleItem.ProgramSchedule; copyScheduleItem.CollectionType = key.CollectionType; copyScheduleItem.MediaItemId = key.MediaItemId; copyScheduleItem.FakeCollectionKey = key.FakeCollectionKey; -- 2.47.3 From 60fecd18b767ef6bc517040b44f7e06f37070e3e Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 23 Jul 2026 08:18:15 +0200 Subject: [PATCH 13/14] chore(392): regenerate migration + OpenAPI + decisions catalog after rebase onto main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto origin/main (which merged #74's ChannelGraphicsElement migration) left the PadToNearestMinute migration's embedded Designer.cs model snapshot stale — it still reflected the pre-#74 model, so EF's diff against it produced an empty Up()/Down() when naively regenerated. Reset TvContextModelSnapshot.cs to origin/main's true post-#74 state, then re-ran scripts/add-migration.sh so the migration's Designer.cs correctly folds in ChannelGraphicsElement and the migration's Up() adds only the PadToNearestMinute column. Verified has-pending-model-changes is clean for both providers. OpenAPI (v1.json/v1.d.ts/endpoint-index.md) and docs/decisions/README.md regenerated identically to the auto-merged state, so nothing to commit there — confirmed both padToNearestMinute and #74's graphics-elements endpoints/decision key are present. Stripped a UTF-8 BOM this dotnet-ef/dotnet-format toolchain wrote into the regenerated migration + snapshot files (known BOM trap, ersatztv#311). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...amSchedule_PadToNearestMinute.Designer.cs} | 40 ++++++++++++++++++- ...Add_ProgramSchedule_PadToNearestMinute.cs} | 0 ...amSchedule_PadToNearestMinute.Designer.cs} | 40 ++++++++++++++++++- ...Add_ProgramSchedule_PadToNearestMinute.cs} | 0 4 files changed, 78 insertions(+), 2 deletions(-) rename ErsatzTV.Infrastructure.MySql/Migrations/{20260722194743_Add_ProgramSchedule_PadToNearestMinute.Designer.cs => 20260723061238_Add_ProgramSchedule_PadToNearestMinute.Designer.cs} (99%) rename ErsatzTV.Infrastructure.MySql/Migrations/{20260722194743_Add_ProgramSchedule_PadToNearestMinute.cs => 20260723061238_Add_ProgramSchedule_PadToNearestMinute.cs} (100%) rename ErsatzTV.Infrastructure.Sqlite/Migrations/{20260722194624_Add_ProgramSchedule_PadToNearestMinute.Designer.cs => 20260723061139_Add_ProgramSchedule_PadToNearestMinute.Designer.cs} (99%) rename ErsatzTV.Infrastructure.Sqlite/Migrations/{20260722194624_Add_ProgramSchedule_PadToNearestMinute.cs => 20260723061139_Add_ProgramSchedule_PadToNearestMinute.cs} (100%) diff --git a/ErsatzTV.Infrastructure.MySql/Migrations/20260722194743_Add_ProgramSchedule_PadToNearestMinute.Designer.cs b/ErsatzTV.Infrastructure.MySql/Migrations/20260723061238_Add_ProgramSchedule_PadToNearestMinute.Designer.cs similarity index 99% rename from ErsatzTV.Infrastructure.MySql/Migrations/20260722194743_Add_ProgramSchedule_PadToNearestMinute.Designer.cs rename to ErsatzTV.Infrastructure.MySql/Migrations/20260723061238_Add_ProgramSchedule_PadToNearestMinute.Designer.cs index cbe30639b..d2e6b1d1a 100644 --- a/ErsatzTV.Infrastructure.MySql/Migrations/20260722194743_Add_ProgramSchedule_PadToNearestMinute.Designer.cs +++ b/ErsatzTV.Infrastructure.MySql/Migrations/20260723061238_Add_ProgramSchedule_PadToNearestMinute.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace ErsatzTV.Infrastructure.MySql.Migrations { [DbContext(typeof(TvContext))] - [Migration("20260722194743_Add_ProgramSchedule_PadToNearestMinute")] + [Migration("20260723061238_Add_ProgramSchedule_PadToNearestMinute")] partial class Add_ProgramSchedule_PadToNearestMinute { /// @@ -406,6 +406,21 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations b.ToTable("Channel", (string)null); }); + modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b => + { + b.Property("ChannelId") + .HasColumnType("int"); + + b.Property("GraphicsElementId") + .HasColumnType("int"); + + b.HasKey("ChannelId", "GraphicsElementId"); + + b.HasIndex("GraphicsElementId"); + + b.ToTable("ChannelGraphicsElement"); + }); + modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b => { b.Property("Id") @@ -4739,6 +4754,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations b.Navigation("Watermark"); }); + modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("ChannelGraphicsElements") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement") + .WithMany("ChannelGraphicsElements") + .HasForeignKey("GraphicsElementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("GraphicsElement"); + }); + modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b => { b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") @@ -6784,6 +6818,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations { b.Navigation("Artwork"); + b.Navigation("ChannelGraphicsElements"); + b.Navigation("Playouts"); }); @@ -6830,6 +6866,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations { b.Navigation("BlockItemGraphicsElements"); + b.Navigation("ChannelGraphicsElements"); + b.Navigation("DecoGraphicsElements"); b.Navigation("PlayoutItemGraphicsElements"); diff --git a/ErsatzTV.Infrastructure.MySql/Migrations/20260722194743_Add_ProgramSchedule_PadToNearestMinute.cs b/ErsatzTV.Infrastructure.MySql/Migrations/20260723061238_Add_ProgramSchedule_PadToNearestMinute.cs similarity index 100% rename from ErsatzTV.Infrastructure.MySql/Migrations/20260722194743_Add_ProgramSchedule_PadToNearestMinute.cs rename to ErsatzTV.Infrastructure.MySql/Migrations/20260723061238_Add_ProgramSchedule_PadToNearestMinute.cs diff --git a/ErsatzTV.Infrastructure.Sqlite/Migrations/20260722194624_Add_ProgramSchedule_PadToNearestMinute.Designer.cs b/ErsatzTV.Infrastructure.Sqlite/Migrations/20260723061139_Add_ProgramSchedule_PadToNearestMinute.Designer.cs similarity index 99% rename from ErsatzTV.Infrastructure.Sqlite/Migrations/20260722194624_Add_ProgramSchedule_PadToNearestMinute.Designer.cs rename to ErsatzTV.Infrastructure.Sqlite/Migrations/20260723061139_Add_ProgramSchedule_PadToNearestMinute.Designer.cs index 18ec70c18..0ae06b798 100644 --- a/ErsatzTV.Infrastructure.Sqlite/Migrations/20260722194624_Add_ProgramSchedule_PadToNearestMinute.Designer.cs +++ b/ErsatzTV.Infrastructure.Sqlite/Migrations/20260723061139_Add_ProgramSchedule_PadToNearestMinute.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace ErsatzTV.Infrastructure.Sqlite.Migrations { [DbContext(typeof(TvContext))] - [Migration("20260722194624_Add_ProgramSchedule_PadToNearestMinute")] + [Migration("20260723061139_Add_ProgramSchedule_PadToNearestMinute")] partial class Add_ProgramSchedule_PadToNearestMinute { /// @@ -393,6 +393,21 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations b.ToTable("Channel", (string)null); }); + modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b => + { + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("GraphicsElementId") + .HasColumnType("INTEGER"); + + b.HasKey("ChannelId", "GraphicsElementId"); + + b.HasIndex("GraphicsElementId"); + + b.ToTable("ChannelGraphicsElement"); + }); + modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b => { b.Property("Id") @@ -4564,6 +4579,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations b.Navigation("Watermark"); }); + modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelGraphicsElement", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("ChannelGraphicsElements") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement") + .WithMany("ChannelGraphicsElements") + .HasForeignKey("GraphicsElementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("GraphicsElement"); + }); + modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b => { b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") @@ -6609,6 +6643,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations { b.Navigation("Artwork"); + b.Navigation("ChannelGraphicsElements"); + b.Navigation("Playouts"); }); @@ -6655,6 +6691,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations { b.Navigation("BlockItemGraphicsElements"); + b.Navigation("ChannelGraphicsElements"); + b.Navigation("DecoGraphicsElements"); b.Navigation("PlayoutItemGraphicsElements"); diff --git a/ErsatzTV.Infrastructure.Sqlite/Migrations/20260722194624_Add_ProgramSchedule_PadToNearestMinute.cs b/ErsatzTV.Infrastructure.Sqlite/Migrations/20260723061139_Add_ProgramSchedule_PadToNearestMinute.cs similarity index 100% rename from ErsatzTV.Infrastructure.Sqlite/Migrations/20260722194624_Add_ProgramSchedule_PadToNearestMinute.cs rename to ErsatzTV.Infrastructure.Sqlite/Migrations/20260723061139_Add_ProgramSchedule_PadToNearestMinute.cs -- 2.47.3 From bb3e245b3c4640f5e139bdc459001f12e3f200c9 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 23 Jul 2026 08:27:02 +0200 Subject: [PATCH 14/14] docs(392): drop predecessor cross-ref line (append-only decisions diff) --- docs/decisions.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 6e51946aa..939ce4ffa 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1305,8 +1305,6 @@ into a schedule item's roll slot. That toggle is mostly SPA work and is deferred the #388 design-system sync epic** (all UI work is currently gated on #388), which also covers adding a 60-minute increment to the filler-preset editor's options (the backend already accepts any integer). -Extended by `sched.clock-padding-schedule-toggle` (#392). - ## 2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380) `key: sched.shuffle-source-builder` · `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. -- 2.47.3